Bit to Board

Learn embedded systems through projects, not slides.

Project-based learning for firmware engineers who learn by building. Every course ends with a working product on your desk: real hardware, real firmware, every code step compile-verified before it ever reaches you.

11 chapters in course #1 94 quiz questions with answer keys 25 code steps, all build-verified
A real electronics workbench: monitor, oscilloscope, multimeters, hand tools and a development board mid-project
A working electronics bench, shown for illustration. Photo: James Bastow (CC BY-SA 2.0), via Wikimedia Commons.
Photo of the nRF52840 DK (PCA10056). The board this course is built on
Featured course

Zigbee, Engineered

Build a real Zigbee occupancy sensor: an nRF52840 DK and an HLK-LD2410 mmWave radar, speaking the standard Occupancy Sensing cluster, bound to an IKEA bulb on a network you form yourself from a coordinator shell. Then sign it, update it over the air, and watch the whole thing happen in Wireshark.

  • A working product at the end. Walk in, light on
  • Shell-driven throughout: bdb, zdo, zcl: no GUI magic
  • MCUboot signing, rollback, and Zigbee OTA, done for real
How it works

Buy it. Build it. Actually learn it.

No 40-hour video marathons. You read a short chapter, you build the next step on real hardware, you prove it works.

1

Get the course

One payment, instant access, no expiry. The full text, labs, quizzes and diagrams live right here in the player, nothing to download, nothing to install.

2

Build on real hardware

A dev kit, a dongle, a radar sensor and a store-bought bulb. Each chapter adds one verified step to the same growing firmware.

3

Learn by doing

Labs make it real, quizzes make it stick, and a sniffer capstone makes you prove the system works, from outside your own code.

Inside the lessons

Concepts that move, taught moving

The hardest ideas in these courses are flows, keys changing hands, an update crossing the air block by block. The lessons teach them as animations you control, not slides that wash past. This one is Chapter 10's over-the-air update, exactly as students get it:

Animated diagram

The OTA conversation: the client sips, never gulps

OTA clientproduct · EP 5Networkthe meshOTA serverNCP DK + nrfutilMatch Descriptor Request (broadcast), who serves 0x0019?Match Descriptor Response. "I do"
OTA clientproduct · EP 5Networkthe meshOTA serverNCP DK + nrfutilQuery Next Image: mfr 0x127F, type 0x0141, v0x01020100…SUCCESS: v0x01030000 available
OTA clientwriting slot 1Networkthe meshOTA serverNCP DK + nrfutilImage Block Request (offset 0, ≤64 B)Image Block Response (data)Image Block Request (offset 64…)block 12 of ~6,800
OTA clientstill sensing ✓Networkthe meshOTA serverNCP DK + nrfutilImage Block Request (offset ~278,000…)Image Block Response (data)≈ 63%, and the room is still being watched
OTA clientrebooting…MCUbootborder guardOTA serverjob doneUpgrade End Request, SUCCESSUpgrade End Response, nowreset → verify signature → swap (trial)
Productv1.3.0 ✓ confirmedNetworkrejoinedOTA serveridleboot_write_img_confirmed()same signed update, no cable. The product is finished

Every course includes lifetime email support. Ask about the material any time, and a working engineer replies within 1 to 2 working days. How support works →

The Zigbee journey

Two parts, one network, two kinds of engineering

Part 1: the always-on world. A wall powered router with a mmWave sensor that switches a real bulb. Power is free, the radio never sleeps, and the hard problems are commissioning, clusters and updates. Then Part 2: the battery world. A push-button remote on a coin cell, joining the same network and toggling the same bulb, where every microamp is accounted for and the radio is off almost all the time. Same network. Same bulb. A stricter physics.

Part 1: the always-on world

A router and sensor on wall power. Commissioning, endpoints and clusters, reporting and binding, signed OTA. Where most Zigbee teaching stops.

See Part 1 →

Part 2: the battery world

The same network with the power cord cut: sleepy end devices, polling and parents, power profiling, battery reporting, System OFF, and updates delivered to a device that is usually asleep. Where most real products live.

See Part 2 →

The catalog

One shelf, growing

Every course follows the same pattern: one real product, built step by step, on the same desk.

Why project-based

Datasheets don't teach. Projects do.

Most courses teach you the vocabulary of a technology and leave you alone with the hard part: making it work. Here the order is reversed. You start from a product someone would actually want in their home, and every concept arrives exactly when the build needs it.

  • Concepts land when the hardware demands them, and stick
  • Production patterns, not demo shortcuts (debounce, reporting, rollback)
  • Every step compile-verified against the pinned SDK before publishing
  • You finish with something demonstrable, in a portfolio or an interview
More about the approach →
Pendant lamps glowing over a dining table in a dim room
Catalog

All courses

Mostly paid, one or two free, always project-based, always on real hardware. New courses join the same shelf as they're built.

Guides

Posts from the workbench

Deep, practical walkthroughs: free to read, written to the same standard as the courses: real code, compile-verified, no hand-waving. New posts land here as they're written.

Free guideZigbee · First network

Your first Zigbee network on a desk

Form a Zigbee network by hand from a shell: a dongle coordinator with a key you set yourself, a DK router, a store-bought IKEA bulb joining the same network, raw ZCL commands for all of them, and a sniffer capture of the whole biography, decrypted in Wireshark.

~3 hours, following along Read
Free guideZephyr · Devicetree

Zephyr devicetree, explained by wiring one sensor end to end

Devicetree stops being magic when you follow one sensor through every layer: the board file, your overlay, the merged tree, the generated header, and the driver the node alone selected. A BME280 on an nRF52840 DK, proven from the build's own outputs, broken on purpose, then wired and breathed on.

~3 hours, following along Read
Free guideZephyr · Board bring-up

Create Zephyr board files from scratch

Your own PCB is not a dev kit. Teach Zephyr it exists. Discovery, Kconfig, devicetree, runners and a bring-up app: a complete board definition for a custom nRF52840 board, written file by file and verified from the build's own generated output.

~3 hours, following along Read
Free guideZephyr · Device drivers

Write a Zephyr device driver from scratch

Zephyr has no driver for your sensor? Write one the upstream way (devicetree binding, Kconfig, CMake and the standard sensor API), for a real I2C temperature sensor, with every line compile-verified.

~3 hours, following along Read

Get the next Guide by email

New posts land every so often. The newsletter carries them, plus course launches and product news. No spam, unsubscribe any time.

Please enter a valid email address.
Done ✓: Buttondown is confirming your signup (see the window it opened, and your inbox). Then: one email when the next post, course or product lands. Nothing else.
GuideZephyr · Device drivers~3 hours, following along

Write a Zephyr device driver from scratch

Sooner or later it happens to every embedded engineer: the perfect sensor for your product exists, the datasheet is open on your desk, and Zephyr has never heard of the part. No driver, no binding, nothing to west your way out of. This guide writes the missing driver, end to end, the way an upstream Zephyr driver is written: a devicetree binding, Kconfig, CMake, and a C file that implements the standard sensor API. Then it wires the sensor to a real board, flashes it, and tests it on the desk, because a driver isn't done when it compiles; it's done when it reads the room.

The part is the Maxim MAX30205, a small I2C temperature sensor. We checked first: in the pinned tree this guide uses (nRF Connect SDK v3.4.1, Zephyr 4.4.2), searching drivers/sensor/ and dts/bindings/sensor/ for max30205 returns nothing, no driver, no binding. Perfect. Everything below was built for an nRF52840 DK in exactly that environment, and the build output quoted further down is the real output of the code on this page.

By the end you'll have a reusable driver packaged as a Zephyr module, plus a sample application that reads the temperature once a second through the standard sensor_sample_fetch() / sensor_channel_get() calls, the same calls you'd use for any supported sensor. Follow the steps in order and don't skip the checkpoints: each one catches a specific class of mistake while it's still cheap.

What you need

Hardware (for the desk test in Steps 10 to 11):

  • An nRF52840 DK: the same board the Zigbee, Engineered course uses. Any other Zephyr board with I2C works too; only the overlay changes.
  • A MAX30205 breakout board (bare chips need a PCB and reflow; a breakout is the sane route). Most breakouts strap the address pins for you and carry pull-up resistors (check the silkscreen or the breakout's docs for which address it answers to.
  • Four jumper wires (female-to-female or female-to-male to suit your breakout): power, ground, SDA, SCL.
  • A USB cable for the DK), a real data cable, not a charge-only one.

Software:

  • A working west workspace with the pinned stack: nRF Connect SDK v3.4.1 (Zephyr 4.4.2) and the Zephyr SDK toolchain (arm-zephyr-eabi-gcc 14.3.0). If you took Course 1, your Chapter 1 install is exactly this. Everything is built from the workspace root, in the toolchain terminal that install gives you.
  • A serial terminal for the console (the one from Course 1 Chapter 1, or any other, PuTTY, minicom, screen, VS Code's serial monitor).

Time: about 3 hours end to end, following along, reading, building and the desk test included. Steps 1 to 9 are the driver; Steps 10 to 11 are wire, flash, and test.

Meet the part

A driver is just a datasheet, translated. The MAX30205 datasheet reduces to a handful of facts:

Fact Value
Bus I2C, up to 400 kHz
Address Set by strapping pins A2/A1/A0; all three to GND gives 0x48 (the breakout used here)
Temperature register Pointer 0x00, read-only, 2 bytes, big-endian, signed
Temperature scale 1 LSB = 0.00390625 °C, that is, the raw value is the temperature × 256
Configuration register Pointer 0x01: one-shot mode, shutdown, alarm behaviour
Alarm thresholds T_HYST at 0x02, T_OS at 0x03, driving the part's OS output pin
Behaviour after power-up Converts continuously on its own. Nothing to configure for plain reads

Keep this table open while you work. Every row is spent somewhere specific:

  • The bus and address become a devicetree node (Step 6) (never a #define in C.
  • The temperature register pointer and byte order are the whole of sample_fetch (Step 5).
  • The scale is the whole of channel_get (Step 5).
  • The configuration and threshold registers are deliberately not spent), they're the extension exercise at the end, and leaving them out is what keeps this driver small enough to understand in one sitting.
  • The "converts continuously" row is why init is five lines instead of fifty: there is nothing to wake up, calibrate, or start.

That last fact shapes the whole driver: init has almost nothing to do, sample_fetch is one I2C transaction, and channel_get is one division. Short register maps make honest first drivers. The Zephyr machinery around the part is the real subject, and it's the same machinery no matter how complicated the silicon gets.

One warning about the address, because it is the single most common way this project goes wrong: 0x48 is not "the MAX30205's address", it is the address of one strapping. The datasheet's address table maps the A2/A1/A0 pins to a range of addresses. All three grounded gives 0x48; a breakout that straps them differently answers somewhere else. Your breakout's docs are the authority. The firmware and the hardware must agree on this number or nothing else in this guide matters. Step 10 makes you verify it before power, and the troubleshooting section starts with it when things fail.

What the driver model gives you

Zephyr's deal with you, the driver author, is this:

  • Devicetree describes the hardware. Which bus, which address, which pins, none of that lives in your C code. It lives in a devicetree node, so the same driver serves every board.
  • A binding describes the description. A small YAML file declares what a node with your compatible string means. The build system checks every node against its binding and turns the node into C macros your driver reads.
  • Your driver registers a device. At build time your code creates a struct device per devicetree node, pointing at your init function, your runtime data, your compile-time config, and a table of API functions.
  • Applications never call you directly. They hold the struct device * and call the standard sensor API, which dispatches into your function table. Swap the sensor, keep the application.

Here is that flow for the part we're building:

Devicetree overlay
max30205@48 node

Binding YAML
maxim,max30205

Build system
devicetree.h macros

Driver config
i2c_dt_spec baked from the node

Device instance
max30205_init runs at POST_KERNEL

Your application
sensor_sample_fetch / sensor_channel_get

MAX30205 on I2C0
temperature register 0x00

It helps to know whose job each piece is before you write any of it:

Piece Owned by Changes when…
Binding YAML The driver The part gains a configurable property
Kconfig + CMake The driver Almost never. They're boilerplate shapes
Driver C file The driver The datasheet says so
Overlay node The application/board The part moves bus, address, or board
Application C file The application The product does something new

Everything in the left three rows ships once and is reused forever. The right two rows are per-project. That split is the entire economic argument for writing drivers this way instead of pasting I2C code into main.c.

Before you start: set up the workspace

Why this step exists: every later command assumes you are standing in your west workspace root, in a terminal where the pinned toolchain is on the path, with the project folder in the place the build command expects. Five minutes here prevents the three most confusing build errors later (wrong tree, wrong toolchain, module not found).

  1. Open your toolchain terminal. The terminal your NCS install gives you (Toolchain Manager's "Open Terminal", or a shell where you've sourced the environment). All commands in this guide run there.

  2. Go to your workspace root, the folder that contains zephyr/, nrf/, and modules/ after west update:

    cd <your-workspace-root>
    pwd
    ls

    Checkpoint: ls shows zephyr, nrf, bootloader, modules. If it doesn't, you're not at the root, cd until you are. Building from the wrong folder produces errors that blame files you've never heard of.

  3. Prove the tree is the pinned one. This guide's outputs were produced against NCS v3.4.1 / Zephyr 4.4.2:

    west --version
    grep -m1 "VERSION" zephyr/VERSION

    You should see west 1.5.x and a Zephyr version of 4.4.x. A different Zephyr version doesn't make the guide impossible, but line-level details (macro names, Kconfig symbols) drift between releases. Pin first, improvise later.

  4. Create the project skeleton. This guide keeps its work in a top-level guides/max30205/ folder inside the workspace, so the module and sample sit next to, not inside, the SDK tree. Create every folder now:

    mkdir -p guides/max30205/max30205-driver/dts/bindings/sensor
    mkdir -p guides/max30205/max30205-driver/drivers/sensor/max30205
    mkdir -p guides/max30205/max30205-driver/zephyr
    mkdir -p guides/max30205/sample/src

    Checkpoint: find guides/max30205 -type d lists those six folders. The layout they form is the finished project's shape:

    guides/max30205/
    ├── max30205-driver/                    the driver, as a Zephyr module
    │   ├── zephyr/module.yml               module glue (Step 4)
    │   ├── CMakeLists.txt                  module entry point
    │   ├── Kconfig                         module entry point
    │   ├── dts/bindings/sensor/maxim,max30205.yaml
    │   └── drivers/sensor/max30205/
    │       ├── CMakeLists.txt
    │       ├── Kconfig
    │       └── max30205.c
    └── sample/                             the application
        ├── CMakeLists.txt
        ├── prj.conf
        ├── app.overlay
        └── src/main.c

    Writing it as a module, rather than files scattered in one app, is deliberate: a module is how real out-of-tree drivers ship, and the same folder drops into any future project unchanged.

Step 1: The binding: teach the build your part exists

Why this step exists: devicetree nodes are just text until a binding says what they mean. The binding is also a contract, if a board file misspells a property or forgets reg, the build fails at configure time with the binding's name in the error, instead of producing firmware that talks to a wrong address.

  1. Create the file max30205-driver/dts/bindings/sensor/maxim,max30205.yaml (in the folder you made above). The filename itself matters: bindings are found by matching the node's compatible string to a file named <vendor>,<part>.yaml.

  2. Fill it with this content:

    max30205-driver/dts/bindings/sensor/maxim,max30205.yaml:

# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

description: |
  Maxim MAX30205 human body temperature sensor (I2C).
  Measures temperature continuously after power-up and exposes it in a
  signed 16-bit register scaled by 1/256 degC per LSB.

compatible: "maxim,max30205"

include: [sensor-device.yaml, i2c-device.yaml]
  1. Read it before moving on, three things to notice:
    1. compatible: "maxim,max30205" is the string everything keys off. The overlay node will carry it (Step 6), the build generates a DT_HAS_MAXIM_MAX30205_ENABLED flag from it, and the driver's #define DT_DRV_COMPAT maxim_max30205 (Step 5) matches it. One string, three spellings (comma, all-caps-underscore, lowercase-underscore). The build system translates between them; you just have to be consistent with each context's convention.
    2. include: [sensor-device.yaml, i2c-device.yaml] inherits the standard property sets instead of redeclaring them. i2c-device.yaml is what makes reg mean "I2C address" and gives the node a bus; sensor-device.yaml adds the properties all sensors share. This driver needs no part-specific properties, so the file ends there. If your part needed, say, a conversion-rate setting, it would be declared here as a property, and only here, because the binding is the single place the build validates properties against.
    3. The binding lives inside the module, under dts/bindings/. Because module.yml (Step 4) declares this module's dts_root, the build searches it automatically. No central registry to edit, no file in the SDK tree to touch, which is exactly why the driver can ship as a folder you copy around.

Checkpoint: the file exists at that exact path, and its compatible line reads "maxim,max30205". A typo here doesn't error yet. It errors three steps later, disguised as something else (Step 5's driver finding no instances). Check it now while the fix is free.

Step 2: Kconfig: the on/off switch, wired to the devicetree

Why this step exists: Zephyr builds are configured by Kconfig, so your driver needs a symbol. The trick worth copying is how it turns on: not by the user remembering to enable it, but automatically, the moment an enabled node with your compatible appears in the devicetree. Hardware described → driver built. Hardware absent → driver costs zero bytes and zero thought.

  1. Create the driver's Kconfig, max30205-driver/drivers/sensor/max30205/Kconfig, with this content:
# MAX30205 temperature sensor configuration options

# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

config MAX30205
    bool "MAX30205 Temperature Sensor"
    default y
    depends on DT_HAS_MAXIM_MAX30205_ENABLED
    select I2C
    help
      Enable the driver for the Maxim MAX30205 I2C temperature sensor.
  1. Understand the three moves it makes:
    • depends on DT_HAS_MAXIM_MAX30205_ENABLED, that symbol exists only when the devicetree, checked against your binding, contains an enabled maxim,max30205 node. No node, no symbol, no driver code in the image. This is the wire between Step 1's YAML and this step's switch.
    • default y, combined with that dependency, means: if the hardware is described, build the driver. The application never has to name your part in prj.conf (watch for exactly that absence in Step 8. It's the proof this step worked).
    • select I2C pulls in the bus subsystem the driver needs, so the application doesn't have to know the driver rides I2C at all.
  2. Create the module-root Kconfig, max30205-driver/Kconfig. The build sources a module's Kconfig from the path module.yml names, so the module needs this one-line entry point that pulls the driver's file in:
# SPDX-License-Identifier: Apache-2.0

# Module entry point for Kconfig: pull the driver's own options in.
rsource "drivers/sensor/max30205/Kconfig"

Checkpoint: two Kconfig files exist (one in the module root, one in the driver folder), and the driver's symbol is named MAX30205. If you ever adapt this guide to another part, the symbol name, the compatible, and the DT_HAS_... symbol are a matched set: change all three together or the driver silently never builds (the troubleshooting section shows how to detect that).

Step 3: CMake: two tiny files

Why this step exists: something has to tell the build which .c file to compile, and when. Zephyr's answer is a per-driver CMakeLists.txt that creates a library and adds sources conditionally on the Kconfig symbol, so a disabled driver costs zero bytes, and an enabled one needs no application-side build edits.

  1. Create the driver's CMake file, max30205-driver/drivers/sensor/max30205/CMakeLists.txt:
# SPDX-License-Identifier: Apache-2.0

zephyr_library()

zephyr_library_sources_ifdef(CONFIG_MAX30205 max30205.c)

The one line that matters is the sources_ifdef shape: the .c file joins the build only when CONFIG_MAX30205 is set, and Step 2 wired that symbol to the devicetree. Describe hardware → symbol → source file. That chain is the driver model in one sentence. 2. Create the module root's CMake file, max30205-driver/CMakeLists.txt, the file the build includes because module.yml points at the module root. It just descends into the driver:

# SPDX-License-Identifier: Apache-2.0

# Module entry point. The build includes this file because
# zephyr/module.yml says so; all it does is descend into the driver.
add_subdirectory(drivers/sensor/max30205)

Checkpoint: both CMake files exist. They're boilerplate, for your next driver you'll copy them and change one filename. That's not laziness; upstream drivers look exactly like this too, and recognisable boilerplate is a feature when someone else reads your driver at 2 a.m.

Step 4: module.yml: the glue

Why this step exists: the build finds out-of-tree code through modules. module.yml is the module's declaration: where its Kconfig entry point is, where its CMake entry point is, and where to look for its devicetree bindings. If you've used the nRF Connect SDK's Zigbee add-on, you've already consumed exactly this mechanism. It's how Nordic ships it. (It's also why this guide writes a module instead of dropping the driver inside the sample app: the in-app route genuinely doesn't link for devicetree-instantiated drivers. The module is the supported shape, not a style preference.)

  1. Create max30205-driver/zephyr/module.yml:
# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

name: max30205
build:
  # Kconfig file sourced into the Zephyr Kconfig tree (under
  # Modules > max30205). Path is relative to the root of this module.
  kconfig: Kconfig
  # Folder whose CMakeLists.txt the build includes — "." is the
  # module root itself.
  cmake: .
  settings:
    # Look for devicetree bindings under this module's dts/ folder.
    dts_root: .
  1. Map the three declarations to the files you've already made: the Kconfig path points at Step 2's root file, the CMake path at Step 3's root file, and the dts_root is what makes Step 1's binding discoverable. If any of those paths is wrong, the failure mode is silence. The build succeeds and the driver simply isn't in it. Step 9 exists precisely to catch that silence with evidence instead of vibes.
  2. Know how an application opts in: by naming the module folder at build time, west build ... -- -DZEPHYR_EXTRA_MODULES=<path-to>/max30205-driver (Step 9 does this), or permanently, by making the module a project in a west workspace, where discovery is automatic. For a product, the workspace route is the right end-state; for learning, the explicit flag keeps the mechanism visible.

Checkpoint: module.yml sits inside a zephyr/ folder inside the module root, not at the module root itself. That nested path is the one thing people get wrong here, and the build won't tell them.

Step 5: The driver itself

Why this step exists: everything so far was plumbing. This file is the driver: it turns devicetree instances into devices, and datasheet facts into API calls. It's also the only file that changes substantially when you adapt this guide to a different part. Everything else was shapes; this is substance.

  1. Create max30205-driver/drivers/sensor/max30205/max30205.c with this content:
/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Driver for the Maxim MAX30205 I2C temperature sensor, written from the
 * datasheet. The part converts continuously after power-up; the only
 * register this driver needs is the temperature register (pointer 0x00):
 * a signed, big-endian 16-bit value scaled by 1/256 degC per LSB.
 */

#define DT_DRV_COMPAT maxim_max30205

#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/logging/log.h>
#include <zephyr/sys/byteorder.h>

LOG_MODULE_REGISTER(max30205, CONFIG_SENSOR_LOG_LEVEL);

/* Pointer address of the temperature register. The other registers
 * (0x01 configuration, 0x02 T_HYST, 0x03 T_OS) drive the alarm output;
 * plain temperature reads do not need them.
 */
#define MAX30205_REG_TEMP 0x00

struct max30205_data {
    int16_t raw; /* last fetched temperature, in 1/256 degC units */
};

struct max30205_config {
    struct i2c_dt_spec i2c;
};

static int max30205_sample_fetch(const struct device *dev,
                 enum sensor_channel chan)
{
    const struct max30205_config *cfg = dev->config;
    struct max30205_data *data = dev->data;
    uint8_t reg = MAX30205_REG_TEMP;
    uint8_t rx[2];
    int ret;

    if (chan != SENSOR_CHAN_ALL && chan != SENSOR_CHAN_AMBIENT_TEMP) {
        return -ENOTSUP;
    }

    /* Write the register pointer, then read the two data bytes in one
     * transaction (repeated start between the write and the read).
     */
    ret = i2c_write_read_dt(&cfg->i2c, &reg, sizeof(reg), rx, sizeof(rx));
    if (ret < 0) {
        LOG_ERR("Failed to read the temperature register (%d)", ret);
        return ret;
    }

    data->raw = (int16_t)sys_get_be16(rx);
    return 0;
}

static int max30205_channel_get(const struct device *dev,
                enum sensor_channel chan,
                struct sensor_value *val)
{
    struct max30205_data *data = dev->data;

    if (chan != SENSOR_CHAN_AMBIENT_TEMP) {
        return -ENOTSUP;
    }

    /* sensor_value wants whole degrees in val1 and micro-degrees in
     * val2. raw is signed and scaled by 1/256 degC, so divide it out;
     * both parts keep the sign of raw (-1.5 degC -> val1 = -1,
     * val2 = -500000).
     */
    val->val1 = data->raw / 256;
    val->val2 = (data->raw % 256) * 1000000 / 256;
    return 0;
}

static DEVICE_API(sensor, max30205_driver_api) = {
    .sample_fetch = max30205_sample_fetch,
    .channel_get = max30205_channel_get,
};

static int max30205_init(const struct device *dev)
{
    const struct max30205_config *cfg = dev->config;

    if (!i2c_is_ready_dt(&cfg->i2c)) {
        LOG_ERR("I2C bus %s is not ready", cfg->i2c.bus->name);
        return -ENODEV;
    }

    /* Nothing to configure: the MAX30205 starts converting on its own
     * after power-up. Do one fetch now anyway, so a missing or
     * mis-wired sensor fails at init instead of at first use.
     */
    return max30205_sample_fetch(dev, SENSOR_CHAN_AMBIENT_TEMP);
}

#define MAX30205_INST(inst)                                                \
    static struct max30205_data max30205_data_##inst;                  \
    static const struct max30205_config max30205_config_##inst = {     \
        .i2c = I2C_DT_SPEC_INST_GET(inst),                         \
    };                                                                 \
    SENSOR_DEVICE_DT_INST_DEFINE(inst, max30205_init, NULL,            \
        &max30205_data_##inst, &max30205_config_##inst,            \
        POST_KERNEL, CONFIG_SENSOR_INIT_PRIORITY,                  \
        &max30205_driver_api);

DT_INST_FOREACH_STATUS_OKAY(MAX30205_INST)
  1. Take the guided tour, five pieces, in the order the file presents them:
    1. #define DT_DRV_COMPAT maxim_max30205 comes before everything. It tells the DT_INST_* macros which devicetree nodes this file serves. The underscore form of the binding's compatible string (compare Step 1: comma there, underscore here; that translation is fixed, learn to read both).
    2. Config vs data. max30205_config is compile-time and read-only: it holds an i2c_dt_spec, bus pointer plus address, which the instantiation macro fills from the devicetree node, so no address ever appears in C. max30205_data is the writable runtime state: here, just the last raw reading. Keeping the two apart is what lets the same code serve several sensors on several buses, and it's what makes the config struct safe to live in flash.
    3. sample_fetch does the bus work, and only the bus work: write the register pointer (0x00), read two bytes, stash the big-endian result. i2c_write_read_dt() performs both halves as one transaction with a repeated start, which is what the datasheet's read sequence shows. Fetch stores raw units on purpose. Conversion belongs to the reader, not the wire. If the bus transaction fails, the error propagates to the caller unchanged; the driver doesn't invent a temperature to hide a wiring fault.
    4. channel_get converts. The sensor API's currency is struct sensor_value: whole units in val1, millionths in val2. Raw counts are temperature × 256, so val1 = raw / 256 and val2 = (raw % 256) * 1000000 / 256. Both keep the sign of the raw value, which is why −1.5 °C comes out as val1 = −1, val2 = −500000 and not something subtly wrong. Work that example by hand once and you'll never misread the split-sign convention again. Channels the driver doesn't implement get -ENOTSUP, and channel_get never touches the bus: fetching and reading are separate verbs in this API, which is what lets an application sample once and convert many times (or convert the same sample into two channel types) for free.
    5. The API table and instantiation. DEVICE_API(sensor, ...) builds the dispatch table the generic API calls through. SENSOR_DEVICE_DT_INST_DEFINE creates one device per node (at POST_KERNEL init level, with data, config, and the API table attached), and DT_INST_FOREACH_STATUS_OKAY runs that macro once per enabled node. Two sensors in the overlay, two devices, zero extra code. POST_KERNEL matters: it guarantees the I2C controller (which initialises earlier) exists before this driver's init runs.
    6. init earns its keep. The part needs no configuration, so init does the two things that still matter: it refuses to come up if the I2C bus isn't ready, and it performs one real fetch, so a sensor that isn't soldered down (or an address that doesn't match the strapping) fails loudly at boot instead of returning garbage forever. Every hour this guide saves you later, it saves through that one decision. A driver that validates its hardware at init turns "the readings look weird" mysteries into a single clear boot-time error.

Step 6: The overlay: describe your board

Why this step exists: the driver knows the part; somebody still has to say this board has one, on this bus, at this address. That's the application's overlay. Hardware description lives with the application, not the driver. Get this file right and the same driver serves your next board unchanged; get it wrong and the failures look like driver bugs, which is why this step has two checkpoints.

  1. Create sample/app.overlay:
/* SPDX-License-Identifier: Apache-2.0
 *
 * A MAX30205 on the nRF52840 DK's I2C0 bus (the Arduino-header SDA/SCL
 * pins, P0.26/P0.27). The breakout's address pins are strapped to GND,
 * which the datasheet's address table decodes as 0x48.
 */

/* The node label "max30205" is how the application finds the device. */
&i2c0 {
    status = "okay";

    max30205: max30205@48 {
        compatible = "maxim,max30205";
        reg = <0x48>;
    };
};
  1. Read the three lines that do the work:
    • The node sits under &i2c0, on the nRF52840 DK that's the bus routed to the Arduino-header SDA/SCL pins (P0.26/P0.27). Step 10 wires to exactly those pins; the overlay and the wiring are two descriptions of the same fact, and they must agree.
    • reg = <0x48> must match the breakout's strapped address. Strap the pins differently and this is the only number that changes, in the whole project.
    • The node label max30205 is the application's handle, Step 7 looks the device up by it. Labels are how C code refers to nodes; the unit address (@48) is how humans (and error messages) refer to them.
    • status = "okay" on the bus is easy to forget on boards where the bus ships disabled. A node under a disabled bus is a node that doesn't exist, as far as DT_INST_FOREACH_STATUS_OKAY is concerned. The driver would build zero instances and say nothing.
  2. Why an overlay and not an edit to the board file: the board's own .dts describes what the board has. Your sensor is something you plugged in, so it belongs to the application. Overlays layer onto the board description at build time, which is why the same sample builds for a different board by swapping one file.

Checkpoint: the overlay names &i2c0, the node carries compatible = "maxim,max30205" (character-for-character the binding's string), and reg matches your breakout's strapped address. If you take one habit from this guide, take this one: the overlay is where hardware truth lives, when behaviour and expectation disagree, re-read the overlay first.

Step 7: The application: three API calls

Why this step exists: to prove the point of the whole exercise. The application uses the bog-standard sensor API and contains no MAX30205 knowledge at all beyond a node label. If this file had a register address or a magic scale factor in it, the driver would have failed at its one job.

  1. Create the application source, sample/src/main.c:
/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Sample: fetch the MAX30205's temperature once a second through the
 * standard sensor API and print it. Nothing in this file knows the
 * part is a MAX30205 — swap the overlay node and the same code reads
 * any temperature sensor Zephyr has a driver for.
 */

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/sys/printk.h>

static const struct device *const max30205 = DEVICE_DT_GET(DT_NODELABEL(max30205));

int main(void)
{
    struct sensor_value temp;
    int ret;

    if (!device_is_ready(max30205)) {
        printk("MAX30205 is not ready (check wiring and I2C address)\n");
        return 0;
    }
    printk("Reading %s once a second\n", max30205->name);

    while (1) {
        ret = sensor_sample_fetch(max30205);
        if (ret == 0) {
            ret = sensor_channel_get(max30205,
                         SENSOR_CHAN_AMBIENT_TEMP, &temp);
        }

        if (ret == 0) {
            /* printk does not print floats by default, so print
             * the sensor_value parts with an explicit sign
             * (val2 carries the sign when val1 is 0).
             */
            printk("ambient: %s%d.%06d C\n",
                   (temp.val1 == 0 && temp.val2 < 0) ? "-" : "",
                   temp.val1,
                   temp.val2 < 0 ? -temp.val2 : temp.val2);
        } else {
            printk("read failed (%d)\n", ret);
        }

        k_sleep(K_SECONDS(1));
    }
    return 0;
}
  1. Walk the four things it does:
    1. DEVICE_DT_GET(DT_NODELABEL(max30205)) resolves the node label from Step 6 to the struct device * the driver registered in Step 5. This is the one and only place the application names the hardware.
    2. device_is_ready() is checked before anything else, and thanks to the driver's init (Step 5, piece 6), "ready" doesn't just mean "the device struct exists"; it means the driver's init ran and its test fetch succeeded. A wiring fault therefore stops the program here, with a message that says what to check, instead of streaming plausible-looking garbage.
    3. The loop is the API's two verbs in their intended rhythm: sensor_sample_fetch() (talk to the wire, store raw) then sensor_channel_get() (convert, no bus traffic). One fetch, one conversion, one print, one second of sleep.
    4. The print formats sensor_value's two fields by hand, including the sign handling when val1 is 0 and val2 carries the sign. printk doesn't print floats by default; knowing that saves a baffling afternoon the first time a Zephyr console shows nothing where a float should be.
  2. Create the application's CMake file, sample/CMakeLists.txt. The ordinary three-liner every Zephyr application starts with. Nothing in it mentions the driver or the module; module discovery is the build command's business (Step 9):
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(max30205_sample)

target_sources(app PRIVATE src/main.c)

Checkpoint: search main.c for 0x48, 0x00 (the register), and 256. You should find none of them. The address is in the overlay, the register is in the driver, the scale is in the driver. If any of those numbers leaked into the application, the layering is broken and future-you pays for it.

Step 8: The application's configuration: two lines and an absence

Why this step exists: prj.conf is where an application declares which subsystems it uses. This one is four lines long, and the most important line is the one that isn't there.

  1. Create sample/prj.conf:
# The sensor subsystem and the I2C driver. CONFIG_MAX30205 itself is
# not set here on purpose: the driver's Kconfig defaults it to y
# whenever an enabled maxim,max30205 node exists in the devicetree.
CONFIG_I2C=y
CONFIG_SENSOR=y
  1. Read it the way the build does:
    • CONFIG_SENSOR=y turns on the sensor subsystem, the generic API the application calls. Without it, sensor_sample_fetch doesn't exist and the link fails (which is good: subsystem switches fail loudly).
    • CONFIG_I2C=y turns on the I2C subsystem. The driver also selects I2C (Step 2), so this line is belt-and-braces (stated explicitly because the application knows it plugs something into I2C pins, even if it doesn't know what.
    • CONFIG_MAX30205 is absent), deliberately. Step 2's default y + depends on DT_HAS_MAXIM_MAX30205_ENABLED means the symbol switches itself on because Step 6's overlay describes the hardware. Writing it here anyway would "work", and would quietly defeat the mechanism, because the next engineer to remove the node would still get the driver compiled in, wondering why. Configuration that follows the hardware description never drifts out of sync with it; configuration typed by hand always eventually does.
  2. Know what you would add for a product: a logging backend and CONFIG_MAX30205_LOG_LEVEL_* if you want the driver's own log output, CONFIG_I2C speed options if your bus needs them. None of it is required to read a temperature, and none of it belongs in a first build. Add configuration when a requirement names it, not before.

Checkpoint: prj.conf enables the sensor subsystem and I2C, and does not mention the driver. Step 9 proves (from the build's own generated files, not from hope), that the driver got built anyway.

Step 9: Build it, then prove the driver is inside

Why this step exists: a green build is necessary and nowhere near sufficient. A driver that never got compiled also "builds", the application links, the memory report prints, and the firmware would boot to a "device not ready" because nothing registered the device. This step therefore has two halves: build, then prove, with checks that cannot pass vacuously.

  1. Run the build from your workspace root. -p always forces a pristine build (no stale cache can fake a pass), -b names the board, and the -- forwards everything after it to CMake, here, the module path that opts this build into Step 4's module:

    west build -p always -b nrf52840dk/nrf52840 \
      guides/max30205/sample \
      -- -DZEPHYR_EXTRA_MODULES=$PWD/guides/max30205/max30205-driver
  2. Read the memory report at the end of the output. You should see the build finish with (this is the actual output of building the code on this page):

    Memory region         Used Size  Region Size  %age Used
               FLASH:       26268 B         1 MB      2.51%
                 RAM:        6272 B       256 KB      2.39%

    Checkpoint: the build completes and prints a memory report with those figures (26268 B of flash, small, because this firmware does exactly one thing). Different figures mean a different tree or extra configuration crept in; the build succeeding with wildly different numbers is worth a pause, not a shrug.

  3. Prove the Kconfig mechanism fired. The generated .config is the build's ground truth, not your prj.conf, not your intentions:

    grep CONFIG_MAX30205 build/sample/zephyr/.config
    # CONFIG_MAX30205=y        <- enabled by the devicetree node, not by prj.conf

    Checkpoint: exactly that line, =y. If instead you find # CONFIG_MAX30205 is not set, the chain broke somewhere upstream: the overlay node isn't enabled, the compatible is misspelled, or the module wasn't found. The troubleshooting section walks that chain link by link.

  4. Prove the driver is linked into the image. Symbols don't lie about what got compiled:

    arm-zephyr-eabi-nm build/sample/zephyr/zephyr.elf | grep max30205
    # ... t max30205_init
    # ... t max30205_sample_fetch
    # ... t max30205_channel_get
    # ... r max30205_driver_api

    Checkpoint: all four symbols present, init, the two API functions, and the dispatch table (r for read-only data: the config-side of Step 5's split, living in flash as designed). Those symbols are the driver. With them confirmed, any failure from here on is a hardware conversation, not a build-system one, which is precisely the confidence Step 10 is about to spend.

Step 10: Wire the sensor to the DK

Why this step exists: everything so far described the hardware in files; this step makes the physical world match the description. The overlay (Step 6) claims the sensor is on I2C0 at 0x48. The next ten minutes make that claim true. Wire with the DK unpowered, I2C hot-plugging usually survives, and "usually" is a bad reason to risk a sensor.

  1. Verify your breakout's strapping before touching a wire. Find its address documentation (silkscreen, product page, or schematic) and confirm which address the A2/A1/A0 straps select. This guide's overlay expects 0x48 (all three to GND). If your breakout answers at a different address, stop and change reg = <0x48> in the overlay and rebuild (Step 9) now. The firmware's address must match the hardware's before anything else can work, and editing it after a failed test costs you a debugging session you don't need.

  2. Wire the four lines. The DK's I2C0 is routed to the Arduino-style header, the pins labelled SDA (P0.26) and SCL (P0.27) on the board's underside silkscreen:

    MAX30205 breakout nRF52840 DK Why
    VDD / VIN / 3V3 3.3V (Arduino header power pin) The part runs from 3.3 V here: do not use 5 V; the DK's I2C pins are not 5 V-tolerant in this setup
    GND GND (any Arduino header ground) Common ground, or the bus signals mean nothing
    SDA SDA (P0.26) The data line Step 6's &i2c0 node describes
    SCL SCL (P0.27) The clock line. SDA and SCL look identical on a jumper wire, swapping them is the #1 wiring fault; the driver fails at init and Step 11 tells you what that looks like

    If your breakout brings out address pins, strap A2, A1, A0 to GND for 0x48 (or confirm the board already does, many breakouts hard-strap them and expose nothing).

  3. Pull-ups, once, so they never surprise you: I2C lines idle high through pull-up resistors. Most MAX30205 breakouts carry them, and the DK's board definition enables pull-ups on these pins, in the common case you add nothing. If your breakout has no pull-ups and reads misbehave (see troubleshooting), add 4.7 kΩ from SDA to 3.3 V and 4.7 kΩ from SCL to 3.3 V. Do not stack multiple strong pull-up sets on a short bench bus; one set is correct.

  4. The pre-power inspection. Read it out loud, it takes twenty seconds: power wire to 3.3 V, not 5 V. Ground to ground. SDA to SDA, SCL to SCL (trace each with a finger; "the blue one" is not a scheme). Nothing shorting on the breakout's back. Address confirmed as 0x48. This checklist exists because every fault it catches produces the same symptom in Step 11, and re-checking wires then is slower than checking them now.

Checkpoint: four wires, each verified against the table, address confirmed against the overlay. Power comes in the next step, through the flash, so the board boots straight into the test firmware.

Step 11: Flash it and run the desk test

Why this step exists: this is where the guide stops being about code. You are going to put the verified firmware on real hardware and check that the physical world behaves the way the driver math says it must. Everything in this step is phrased as expected behaviour. Your readings will differ from the examples by whatever your room is doing, and the tests below are designed around that: they check direction, plausibility, and responsiveness, not exact numbers.

  1. Flash the DK. Connect USB (the DK's debugger USB port) and run, from the workspace root:

    west flash -d build/sample

    Checkpoint: the flasher reports the image written and the board resets (you'll see the DK's LEDs blink as it restarts). A "no probe found" error here is a cable/port problem, not a firmware one. Use the DK's USB connector (not the nRF USB port) and a data cable.

  2. Open the console before you reset. The DK exposes a USB serial port (the J-Link COM port on Windows; a /dev/ttyACM* device on Linux/macOS). Connect your terminal to it at 115200 baud, 8N1, then press the DK's RESET button so you catch the boot from the top.

  3. Read the boot. What you should see first is the sample's opening line, the device name comes from the devicetree node, unit address and all:

    Reading max30205@48 once a second

    That line is already a test result: it prints only after device_is_ready() passed, which, per Step 5, means the driver's init performed a successful fetch from the real sensor. If you see this line, the wiring, the address, the bus, the binding, and the driver have all agreed with each other at least once.

  4. Test 1: plausibility (the ambient reading). One line a second follows:

    ambient: 24.250000 C
    ambient: 24.281250 C
    ambient: 24.250000 C

    Pass criteria: the value is in a sane band for your room (roughly 18 to 30 °C indoors), it's stable to within a few hundredths of a degree between reads, and consecutive values move in small steps (note the quantisation: the sensor's step is 0.00390625 °C, so readings land on multiples of it, 24.281250 is 24 + 72/256). A value that is wildly off (say, 85 or −40), frozen to the digit for minutes, or jumping by degrees between seconds is a fail. Go to troubleshooting with the specific behaviour noted; each failure shape points somewhere different.

  5. Test 2: warm the sensor (the responsiveness test). Rest a fingertip firmly on the sensor package (or cup your hands around the breakout and breathe on it) for 15 to 20 seconds, watching the console. Pass criteria: the reading climbs smoothly while you warm it (expect a rise of roughly 1 to 4 °C for a fingertip on the package, more if you breathe warm air directly on it), then, when you let go, decays back toward ambient over the following minute, settling near where Test 1 started. Direction and shape are the test: up promptly, down gently, no steps or spikes. A sensor that doesn't respond to a warm finger is not measuring anything real, whatever the ambient reading looked like; a sensor that jumps 20 °C instantly is not measuring temperature. Both fail here, and both are wiring/bus stories, not driver stories.

  6. Test 3, the cross-check (optional, if you have any reference thermometer). Leave the setup running undisturbed for five minutes next to a room thermometer or a trusted weather station's indoor sensor. Pass criteria: agreement within about 1 °C. The MAX30205 is an accurate part; a persistent multi-degree offset at steady state usually means the sensor is being warmed by its neighbour. Check it's not sitting on top of the DK's regulator or in the exhaust path of your laptop before blaming the code.

  7. Test 4: the power-cycle (proves init isn't luck). Press RESET twice more, a minute apart. Pass criteria: both boots reach the once-a-second readings with no "not ready" and no failed reads. Intermittent init is a physical-layer smell (marginal pull-ups, a tired jumper wire, a strap making poor contact), catching it here, on the bench, is the entire reason this test exists.

Checkpoint: all four tests pass, sane ambient, fingertip rise and decay, agreement with a reference if you had one, boringly repeatable boots. That is a finished driver: verified code, verified wiring, verified behaviour.

The verification boundary, stated plainly: the firmware in this guide was compile-verified end to end in the pinned environment (Step 9's .config and symbol checks are that proof). The console outputs and readings above are the expected behaviour of that verified code on real hardware, your desk check, your readings. If your desk disagrees, the troubleshooting section below is written for exactly that moment.

If it breaks

Work this table top-down by symptom, don't rummage. Each row names the most likely cause first and the check that distinguishes it.

Symptom Most likely cause Check / fix
Build fails: devicetree error naming maxim,max30205 The binding wasn't found. The module wasn't passed to this build, or module.yml isn't in a zephyr/ subfolder Rebuild with the exact -DZEPHYR_EXTRA_MODULES=... flag (Step 9); confirm max30205-driver/zephyr/module.yml exists at that nested path (Step 4)
Build succeeds but Step 9's .config check shows the driver not set The chain from overlay to symbol broke: node disabled, compatible misspelled, or overlay not applied Compare the overlay's compatible character-for-character with the binding (Step 6); confirm status = "okay" reaches &i2c0; confirm the file is named app.overlay in the sample root
nm check finds no max30205_* symbols The driver source never compiled, usually the same causes as the row above, or the driver CMake's sources_ifdef symbol mismatched Compare the symbol in drivers/sensor/max30205/CMakeLists.txt against the Kconfig symbol name (Step 2/Step 3)
Console: MAX30205 is not ready at boot Init's test fetch failed: wiring, power, or address mismatch, in that order of likelihood after a fresh build Re-run Step 10's pre-power inspection; confirm the breakout's strapped address equals reg; try swapping SDA/SCL if they're at all uncertain (a classic, and harmless to test)
Console: read failed (-5) after readings started Bus errors at runtime (-EIO): a marginal connection, missing pull-ups, or a strap moving Reseat the jumpers; check pull-ups (Step 10, item 3); shorten the wires; confirm nothing else drives the bus
Readings frozen at one value forever The "sensor" isn't responding and something else ACKs, or SDA is shorted/stuck Power-cycle; disconnect the breakout and boot. A healthy setup now prints not ready (proving the driver notices absence); if it still "reads", the bus is shorted
Readings plausible but offset by several degrees, steady The sensor is being warmed by its surroundings, not misconverted Move the breakout away from the DK's regulator/LEDs and your laptop's exhaust; re-run Test 3 after five undisturbed minutes
Readings look shifted by a huge factor (e.g., room shows as ~6,200) You're looking at raw counts, not the converted value. The fetch/get split was skipped in your modified code channel_get does the ÷256 (Step 5); if you edited the sample, check you didn't print the raw register
Negative temperatures print strangely (e.g. 0.-500000) The sensor_value split-sign convention mishandled in a modified print val1 and val2 share the raw value's sign (Step 5, piece 4); the stock main.c handles the val1 == 0 case explicitly
west flash: no probe / target not found Wrong USB port on the DK, charge-only cable, or the DK's power switch is off Use the debugger USB connector and a data cable; this is never a firmware problem

And the meta-rule that resolves what the table doesn't: change one thing at a time, and re-run the failing checkpoint, not the whole project. Every step above ends in a checkpoint precisely so a fault can be located to one link of the chain (description, configuration, build, wiring, behaviour), instead of five.

You are done when

  • The binding, both Kconfig files, both CMake files, and module.yml exist at the Step 1 to 4 paths, and you can say what each one is for
  • The driver compiles as a module and Step 9's two proofs pass: CONFIG_MAX30205=y in the generated .config (set by the devicetree, not by prj.conf), and the four max30205_* symbols in zephyr.elf
  • The sample application contains no address, no register pointer, and no scale factor. The layering check from Step 7
  • The sensor is wired per Step 10 (3.3 V, SDA→P0.26, SCL→P0.27, straps matching reg) and the DK boots to once-a-second readings
  • Test 1 passes: a stable, plausible ambient reading, moving in 0.00390625 °C steps
  • Test 2 passes: a fingertip raises the reading by roughly 1 to 4 °C, and it decays back to ambient when released
  • Two extra resets both reach the readings cleanly (Test 4)
  • You can point at any wrong behaviour and say which link of the chain (binding, Kconfig, driver, overlay, wiring), you'd check first, and why

Adapt it to your part

Everything except one file is reusable unchanged. To drive a different sensor:

  1. New binding: copy the YAML, change compatible and the description. Add properties here if your part is configurable.
  2. New Kconfig/CMake: same shapes; rename the symbol; the DT_HAS_<YOUR_COMPAT>_ENABLED dependency follows your compatible automatically.
  3. The C file is where your datasheet goes: the register pointer and byte order in sample_fetch, the scale in channel_get. A humidity channel means a second register read and a second sensor_channel case. The skeleton doesn't change.
  4. Natural next steps for this driver: attr_set for the configuration register (this part's one-shot and shutdown bits live at pointer 0x01), thresholds via T_HYST/T_OS with the OS output wired to a GPIO interrupt, and sensor_trigger support so the application sleeps instead of polling. Each is one API-table row and one datasheet section. You now own the pattern they plug into.
  5. When the driver outgrows a guide: submit it upstream, so the next engineer finds it in the tree. That's where the drivers in drivers/sensor/ come from. Somebody's product needed a part Zephyr didn't have yet. Yours might be next.

← All guides

GuideZephyr · Board bring-up~3 hours, following along

Create Zephyr board files from scratch

Create Zephyr board files from scratch

Every Zephyr tutorial starts the same way: west build -b nrf52840dk/nrf52840. That works beautifully. Right up until the day your own PCB comes back from the fab. Your board is not a development kit. It has your sensor on it, your LED on a different pin, no QSPI flash, maybe no USB. Zephyr has never heard of it, and until you teach it, you cannot build for it at all. The DK target will happily compile your firmware for the wrong board (wrong pins, wrong peripherals, wrong flash layout), and every bug that creates looks like a firmware bug.

This guide writes the missing board definition, from an empty folder to a building, verifiable target. A board definition (board files, board port) is the set of small files that tells Zephyr: this hardware exists, this is what is soldered to it, build like this. Once it exists, your board is a first-class build target, used exactly like any board in the tree: west build -b fieldnode/nrf52840.

The board we will define is the Fieldnode, a plausible custom board we will treat as our own product hardware: a battery-powered sensor node built around a bare nRF52840. We checked first, in the pinned tree this guide uses (nRF Connect SDK v3.4.1, Zephyr 4.4.2), there is no fieldnode anywhere under zephyr/boards/. It does not exist until we create it. Every file below was created in that environment, the sample application at the end was built for the new target, and the outputs quoted in the verification steps are the real outputs of the files on this page.

By the end you will have:

  • A complete board definition: board.yml, the two Kconfig files, the board devicetree and its pinctrl file, a defconfig, the runner configuration, a Twister platform file and a documentation page (each one written as a numbered procedure, with the why for every line that matters.
  • A tiny bring-up application whose whole job is to prove the board files are right, exercising the board's LED, button and console through the standard aliases.
  • A verification routine), board discovery, generated devicetree, generated .config, that proves the build really consumed your files, not something else's.
  • A desk test procedure for the day the hardware (or a stand-in) is on your bench.

Meet the board

A board file is a schematic, translated. Fieldnode's schematic reduces to this:

Fact Value
SoC Nordic nRF52840, QIAA (aQFN73) package: 1 MB flash, 256 KB RAM
Main crystal 32 MHz (the SoC requires it; nothing to declare)
Low-frequency crystal 32.768 kHz crystal fitted (XL1/XL2)
Status LED One green LED on P0.15, wired active-high
User button One push button on P0.24, active-low, internal pull-up
Console UART0 on a debug header: TX P0.06, RX P0.08, 115200 baud, no flow control
Debug SWD header for a SEGGER J-Link
External flash None. No QSPI NOR, everything lives in internal flash
USB Not routed to a connector on this board

Three entries in that table will shape decisions later, so notice them now:

  • The 32.768 kHz crystal is fitted. That single fact decides how the low-frequency clock is configured, and, pleasantly, it means the configuration is the tree's default, which Step 6 cashes in and Step 12 proves from the generated .config.
  • There is no QSPI flash. The nRF52840 DK target describes an external flash chip both to Zephyr and to the flashing tools. Ours must describe neither, and one line the DK needs in its runner file is a line we must leave out (Step 7).
  • The LED is active-high, the button active-low. Polarity is a board property, not an application property. It lives in exactly one place, the devicetree, and Step 13 shows what happens when a board disagrees with the firmware about it.

How the build finds a board: and what it does with one

Before writing anything, it pays to know the machine you are feeding. When you run west build -b fieldnode/nrf52840, roughly this happens:

Your board files
boards/fieldnode/ inside the app

Sysbuild registers the root
BOARD_ROOT in sysbuild/CMakeLists.txt

Board discovery
list_boards reads board.yml

Target resolved
fieldnode/nrf52840: board + SoC + variant

Devicetree merge
SoC dtsi + partition dtsi + your board .dts

Kconfig merge
Kconfig.fieldnode + board defconfig + prj.conf

Generated truth
zephyr.dts and .config

Your application
built for your board

Read it as three ideas:

  1. Discovery is declarative. The build scans board roots, folders that contain a boards/ directory, and reads every board.yml it finds. A board exists, as far as the build system is concerned, when its board.yml is somewhere the scan can see. Step 9 exists because an application folder is not scanned by default under sysbuild, and connecting the two is the single most common failure in this whole exercise (you will meet its error message in "If it breaks", because we met it while writing this guide).
  2. The target string is two facts, not one. fieldnode/nrf52840 is board / SoC. The board files answer both halves: board.yml names the SoC the board carries, and the board's Kconfig selects the exact silicon variant (package) of that SoC. Mixing those two levels up is the classic beginner mistake; Steps 1 and 2 keep them deliberately separate.
  3. Your files are merged, not compiled. The board devicetree is layered over the SoC's own devicetree include files; the board defconfig is layered under your application's prj.conf. The output of that merge, a generated zephyr.dts and a generated .config inside the build folder, is the ground truth of what your board became. Step 12 verifies the board by reading the generated files, never by re-reading the files we wrote. That habit is worth more than any single file in this guide.

Where boards live

Zephyr finds boards in three kinds of places, and choosing between them is a project decision, not a technicality:

  • In the Zephyr tree (zephyr/boards/<vendor>/<board>/), where the DK lives. Submitting a board there is how it ships to everyone; it is also the wrong place for your product's board during development, because your hardware should version with your firmware, not with an SDK.
  • In a module. A Zephyr module can carry a boards/ folder, the same mechanism Guide 1 used to carry a driver. Right when several products share a board platform or a board vendor ships Zephyr support.
  • In the application (<app>/boards/<board>/). The board lives in the product's own repository, next to the code that runs on it. This is where most real custom boards start, and it is where ours will live. If the board later graduates to a module or upstream, the folder moves unchanged: the files do not care which root they were found under.

The project layout follows from that choice. One application folder carries everything:

guides/fieldnode/
└── sample/                             the bring-up application
    ├── CMakeLists.txt
    ├── prj.conf
    ├── sysbuild/
    │   └── CMakeLists.txt              registers the board root (Step 9)
    ├── src/
    │   └── main.c                      the bring-up check (Step 10)
    └── boards/
        └── fieldnode/                  the board definition
            ├── board.yml               discovery: name, vendor, SoC (Step 1)
            ├── Kconfig.fieldnode       board symbol → SoC variant (Step 2)
            ├── Kconfig.defconfig       board-level Kconfig defaults (Step 3)
            ├── fieldnode_nrf52840_defconfig   baseline config (Step 4)
            ├── fieldnode_nrf52840-pinctrl.dtsi   pin control (Step 5)
            ├── fieldnode_nrf52840.dts  the hardware description (Step 6)
            ├── board.cmake             flash/debug runners (Step 7)
            ├── fieldnode_nrf52840.yaml Twister platform file (Step 8)
            └── doc/index.rst           the board's doc page (Step 8)

Ten small files. None of them is long, each of them has exactly one job, and the rest of this guide takes them in dependency order.

Before you start: the procedures assume the environment the Zigbee course sets up in its first chapter, the pinned NCS v3.4.1 toolchain on your PATH, west working, and a shell sitting in your west workspace. Every command below was run in exactly that setup. File paths in commands are relative to the guide's project folder (guides/fieldnode/); adjust the one path in Step 11 if you keep your application elsewhere.

Step 1: board.yml: declare that the board exists

Why this step exists: board discovery (box C in the diagram) reads exactly one file to learn a board exists and what it is called: board.yml. No board.yml, no board. The build will not even warn about your other nine files, because as far as it knows there is nothing to warn about.

Procedure:

  1. Create the folder that will hold the definition: sample/boards/fieldnode/. The folder name must be the board's name; discovery keys off it.
  2. Create board.yml inside it with the content below.
  3. Read it against the checklist after the file. Every field is load-bearing.

sample/boards/fieldnode/board.yml:

board:
  name: fieldnode
  full_name: Fieldnode
  vendor: fieldnode
  socs:
  # The one and only SoC this board is built around: a bare
  # nRF52840 (QIAA package) on the sensor-node PCB.
  - name: nrf52840

What each field does, and what it does not do:

  • name: fieldnode is the board's identity. It appears in the build target (fieldnode/nrf52840), in generated symbols (CONFIG_BOARD_FIELDNODE), and in file names you will write in later steps. Pick it once, in lowercase, and never rename it casually. A board rename is a breaking change for every build script and CI job that names the target.
  • full_name is for humans: listings and documentation. It carries no build meaning.
  • vendor: fieldnode groups boards by maker in listings. In-tree, vendors own top-level folders (boards/nordic/…); for an application board the vendor is a label, and using your project or company name is the honest choice.
  • socs: - name: nrf52840 is the half of the target string this file owns: which SoC the board carries. Note carefully what it says, nrf52840, the SoC, and not nrf52840_qiaa, the package variant. The SoC layer of Zephyr already knows what an nRF52840 is (its CPU, its flash and RAM, its peripheral set) because the SoC is described once, centrally, in the tree. Your board.yml only has to point at it. The variant, which package of that SoC is soldered down, is a different fact, owned by a different file, in the next step. Putting the variant string here is the classic mistake: discovery will look for an SoC literally named nrf52840_qiaa, not find one, and the build stops before your devicetree is ever read.

One more decision is embedded in this file: the list has one SoC. A board that ships in two silicon flavours (the DK does exactly this, carrying an nRF52811 emulation mode as a second entry) lists both, and each becomes its own target, fieldnode/nrf52840 and a sibling. Fieldnode has one hardware reality, so it gets one entry.

Checkpoint: sample/boards/fieldnode/board.yml exists, says name: fieldnode, and its SoC entry is nrf52840 with no package suffix.

Step 2: Kconfig.fieldnode: pick the exact silicon

Why this step exists: board.yml said "an nRF52840". The chip on the reel, though, is a specific package (QIAA, the 7×7 mm aQFN73), and the package decides facts the software can see, down to which GPIO pins physically exist (the smaller packages bond out fewer). Somebody has to select the SoC variant symbol, and by convention that somebody is the board's own Kconfig file, Kconfig.<board>.

Procedure:

  1. Create Kconfig.fieldnode in the board folder with the content below.
  2. Compare it against what it is not: there is no config block defining BOARD_FIELDNODE itself. The build system generates the board symbols (BOARD_FIELDNODE, and one per target, BOARD_FIELDNODE_NRF52840) from board.yml; this file's job is only to hang configuration off them.

sample/boards/fieldnode/Kconfig.fieldnode:

# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

config BOARD_FIELDNODE
    select SOC_NRF52840_QIAA if BOARD_FIELDNODE_NRF52840

The one line does two things worth separating:

  • select SOC_NRF52840_QIAA pulls in the variant: the pin map, the flash/RAM sizes, the peripheral instances the package exposes. Every driver and library decision downstream reads variant symbols, so this select is the board's single most consequential line of configuration.
  • if BOARD_FIELDNODE_NRF52840 guards it per target. A one-SoC board does not strictly need the guard today, but the day board.yml grows a second SoC, the unguarded select would force QIAA onto a target it does not belong to. Board files are written once and read for years; write the guard now, while it is free.

Checkpoint: the file selects exactly one variant symbol, and the variant (QIAA) matches the package in the board's spec table, not the package on some other board you copied from. Copying the DK's file verbatim would select the same variant here (the DK also uses QIAA), which is luck, not method: the method is read your own BOM.

Step 3: Kconfig.defconfig: board defaults, not board law

Why this step exists: some configuration is a property of the board, shared by every application that will ever run on it, but is still a default. A well-behaved application may override it. Kconfig needs a place for "when this board is the target, default these symbols so", kept separate from the defconfig's baseline (Step 4). That place is Kconfig.defconfig.

Procedure:

  1. Create Kconfig.defconfig in the board folder with the content below.
  2. Note the shape: an if BOARD_FIELDNODE wrapper, and inside it only default statements on symbols defined elsewhere. This file defines no new symbols and selects nothing, if you find yourself writing select here, the line belongs in Step 2's file or Step 4's.

sample/boards/fieldnode/Kconfig.defconfig:

# Fieldnode board configuration

# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

if BOARD_FIELDNODE

# Turn on hardware stack protection wherever the SoC offers it.
# This is a *default*, not a mandate: an application can still
# switch it off in its own prj.conf.
config HW_STACK_PROTECTION
    default ARCH_HAS_STACK_PROTECTION

endif # BOARD_FIELDNODE

The single example is deliberately small and real: hardware stack protection. The nRF52840's MPU can guard thread stacks, the SoC layer advertises that ability as ARCH_HAS_STACK_PROTECTION, and a board that wants the protection on by default says so here, once, instead of relying on every application to remember. Because it is a default, an application that must turn it off (a bootloader with unusual constraints, say) still can, in its own prj.conf. Board policy advises, the application decides.

You will know this file is working in Step 12 without touching hardware: the generated .config will contain CONFIG_HW_STACK_PROTECTION=y, a line that exists in no file we wrote as a =y assignment. It got there by default, through this file. That is the proof pattern for every board default you add later.

Checkpoint: the file contains defaults only, wrapped in the board guard. Anything stronger does not belong here.

Step 4: The defconfig: the board's baseline configuration

Why this step exists: when any application builds for this board, a pile of configuration has to come from somewhere before the application's own prj.conf is even read: Is there a console? Is GPIO compiled in? Is the MPU on? fieldnode_nrf52840_defconfig is that baseline, the board's answer to "what does every Fieldnode firmware get, before the application expresses an opinion?" Applications layer on top; they should never have to re-declare that the board has a console.

Note the file's name before its content: <board>_<soc>_defconfig. Like the devicetree in Step 6, the name is a contract. The build looks for exactly this pattern, and a defconfig named anything else is a file the build will never open.

Procedure:

  1. Create fieldnode_nrf52840_defconfig in the board folder with the content below.
  2. For each line, find its consequence in the right-hand column of the table that follows. If you cannot state a line's consequence, the line does not go in your board. Cargo-culted defconfig lines are how boards acquire phantom behaviour nobody can explain two years later.

sample/boards/fieldnode/fieldnode_nrf52840_defconfig:

# SPDX-License-Identifier: Apache-2.0

# Enable MPU
CONFIG_ARM_MPU=y

# enable GPIO
CONFIG_GPIO=y

# enable uart driver
CONFIG_SERIAL=y

# enable console
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
Line What it buys What breaks without it
CONFIG_ARM_MPU=y The memory protection unit driver. Prerequisite for the stack protection defaulted in Step 3 Step 3's default has nothing to stand on
CONFIG_GPIO=y The GPIO subsystem, so the LED and button in the devicetree become devices The gpio-leds/gpio-keys nodes describe hardware no driver claims
CONFIG_SERIAL=y The UART driver family No serial device for a console to attach to
CONFIG_CONSOLE=y + CONFIG_UART_CONSOLE=y printk output routed to a UART, the board's voice The board builds, flashes, runs, and says nothing, forever

That last row deserves emphasis, because it is the most common board bring-up failure there is: a missing console configuration produces no error anywhere. The build is green, the flash succeeds, the application runs. The terminal is simply silent, and the silence looks exactly like a dead board. A board defconfig exists precisely so this baseline is decided once, by the board author, instead of being rediscovered per application. Our bring-up sample in Step 10 leans on this: its own prj.conf will be nearly empty, and the console will still work, because this file did its job.

What is not in the defconfig is as deliberate as what is. There is no Bluetooth, no networking, no sensor configuration: those are application opinions, and putting them here would force every future application to spend flash on, or actively undo, the board author's guesses. And there is no low-frequency-clock line. Hold that thought; Step 6 explains why the 32.768 kHz crystal needs no line, and Step 12 proves it.

Checkpoint: five assignments, each traceable to a row in the table. If your board needs a sixth (a board with an I2C bus expander every application uses, say), it earns its row in the table first.

Step 5: Pinctrl: which pins the UART owns

Why this step exists: on the nRF52840, almost any peripheral signal can be routed to almost any pin. That freedom has to be resolved somewhere, and Zephyr resolves it with pinctrl: named pin configurations, described in devicetree, that a peripheral node points at. Board files keep pinctrl in its own file, fieldnode_nrf52840-pinctrl.dtsi, because pin routing is the part of a board definition that changes most often between board revisions, and isolating it keeps a revision's diff readable.

Procedure:

  1. Create fieldnode_nrf52840-pinctrl.dtsi in the board folder with the content below.
  2. Check the pin numbers against the spec table: TX P0.06, RX P0.08. The NRF_PSEL(UART_TX, 0, 6) macro reads as function, port, pin, (UART_TX, 0, 6) is P0.06.

sample/boards/fieldnode/fieldnode_nrf52840-pinctrl.dtsi:

/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 */

&pinctrl {
    uart0_default: uart0_default {
        group1 {
            psels = <NRF_PSEL(UART_TX, 0, 6)>;
        };

        group2 {
            psels = <NRF_PSEL(UART_RX, 0, 8)>;
            bias-pull-up;
        };
    };

    uart0_sleep: uart0_sleep {
        group1 {
            psels = <NRF_PSEL(UART_TX, 0, 6)>,
                <NRF_PSEL(UART_RX, 0, 8)>;
            low-power-enable;
        };
    };
};

Three things to take from this small file:

  • Two states, always. uart0_default is the configuration while the UART is in use; uart0_sleep is what the pins become when the device sleeps, low-power-enable disconnects them so a powered-down peripheral does not leak current through a header cable. Battery-powered boards live or die on details like this, and the pair is the convention: a peripheral node in Step 6 will name both, as pinctrl-0 and pinctrl-1.
  • bias-pull-up on the RX group only. An unconnected RX pin floats, and a floating UART input reads as garbage and breaks. The pull-up holds the line at its idle level when the debug cable is unplugged. TX needs nothing: it is driven.
  • No flow control. The spec table says the header carries TX/RX only, so there are no RTS/CTS psels. The DK's pinctrl file defines four pins for UART0 because the DK routes hardware flow control; copying its groups wholesale into a two-wire board describes pins that do not exist on the header. Pinctrl is where board files most often lie by inheritance. Write only the wires your board has.

Checkpoint: two groups in the default state (TX alone, RX with pull-up), one combined group in sleep. If your board adds a second UART or an I2C bus later, each gets its own named groups in this same file.

Step 6: The board devicetree: the hardware, described once

Why this step exists: everything so far was metadata and configuration. This file is the board: the one place that says what is soldered down and where. Applications, drivers and samples all read the hardware from here (through aliases and chosen nodes, never by hard-coding pins), which is what makes application code portable between your board and a DK. It is the longest file in the definition and the step that repays the most care.

Procedure:

  1. Create fieldnode_nrf52840.dts in the board folder with the content below. Like the defconfig, the name is the contract: <board>_<soc>.dts.
  2. Write it in the order the file presents: the include chain first, then the board's own nodes, then the peripheral enablement at the bottom. The guided tour after the file walks each block.

sample/boards/fieldnode/fieldnode_nrf52840.dts:

/*
 * Copyright (c) 2026 Bit to Board
 *
 * SPDX-License-Identifier: Apache-2.0
 *
 * Fieldnode: a battery-powered sensor node built around a bare
 * nRF52840. The board fits a 32.768 kHz crystal for the low-frequency
 * clock (the Kconfig default already selects it — see the guide),
 * one status LED, one user button, and a UART console on the debug
 * header. There is deliberately no QSPI flash and no USB connector.
 */

/dts-v1/;
#include <nordic/nrf52840_qiaa.dtsi>
#include <nordic/nrf52840_partition.dtsi>
#include "fieldnode_nrf52840-pinctrl.dtsi"
#include <zephyr/dt-bindings/input/input-event-codes.h>

/ {
    model = "Fieldnode NRF52840";
    compatible = "fieldnode,fieldnode-nrf52840";

    chosen {
        zephyr,console = &uart0;
        zephyr,shell-uart = &uart0;
        zephyr,uart-mcumgr = &uart0;
    };

    leds {
        compatible = "gpio-leds";

        led0: led_0 {
            gpios = <&gpio0 15 GPIO_ACTIVE_HIGH>;
            label = "Green LED";
        };
    };

    buttons {
        compatible = "gpio-keys";

        button0: button_0 {
            gpios = <&gpio0 24 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>;
            label = "User button";
            zephyr,code = <INPUT_KEY_0>;
        };
    };

    aliases {
        led0 = &led0;
        sw0 = &button0;
        watchdog0 = &wdt0;
    };
};

/* The board is battery powered: run the internal regulator in
 * DC/DC mode, which the DK uses too and which costs nothing at
 * build time but real microamps at run time.
 */
&reg1 {
    regulator-initial-mode = <NRF5X_REG_MODE_DCDC>;
};

&gpiote {
    status = "okay";
};

&gpio0 {
    status = "okay";
    /* P0.00/P0.01 are the 32.768 kHz crystal pins (XL1/XL2):
     * they belong to the clock, never to an application. */
    gpio-reserved-ranges = <0 2>;
    gpio-line-names = "XL1", "XL2", "", "", "", "", "TXD", "",
              "RXD", "", "", "", "", "", "", "LED",
              "", "", "", "", "", "", "", "",
              "BUTTON", "", "", "", "", "", "", "";
};

&gpio1 {
    status = "okay";
};

&uart0 {
    compatible = "nordic,nrf-uarte";
    status = "okay";
    current-speed = <115200>;
    pinctrl-0 = <&uart0_default>;
    pinctrl-1 = <&uart0_sleep>;
    pinctrl-names = "default", "sleep";
};

The include chain: stand on the SoC's description

The board file does not describe the nRF52840. That description already exists, written once by the silicon vendor's ecosystem, and the first includes pull it in:

  • <nordic/nrf52840_qiaa.dtsi>: the SoC itself: CPU, flash and RAM nodes, every peripheral instance with its registers and interrupts, all status = "disabled" until a board says otherwise. Note the variant again: the include names the package (QIAA), matching Step 2's Kconfig select. Two files, one fact, stated in the two places the build reads it from (if they ever disagree, believe neither and go back to the BOM.
  • <nordic/nrf52840_partition.dtsi>), Nordic's standard internal-flash layout, and the highest-leverage include in the file. It defines, under the SoC's flash node: a mcuboot partition at 0x000000 (48 KiB), image-0 at 0x00C000 (472 KiB), image-1 at 0x082000 (472 KiB) and a 32 KiB storage partition at 0x0F8000, and it sets the zephyr,code-partition chosen node to image-0. Those labels are not decoration: MCUboot (Course 1, Chapter 9) and the OTA machinery (Chapter 10) find their slots by exactly these names. A product board that invents its own names to be different will spend a week discovering why. Our board has no external flash and a completely standard internal layout, so it takes the standard file.
  • The board's own pinctrl file from Step 5, included by relative path.

The board's own nodes

  • model and compatible name the description itself. They land in the generated devicetree and in the firmware's device model; compatible follows the same vendor,board-soc pattern the in-tree boards use.
  • chosen is where the board answers the questions the whole system asks. zephyr,console = &uart0 is the single most important line in the file, in the sense of most expensive to omit: it is how printk, logging and the shell find their UART. Leave it out and nothing fails to build. The board simply has no voice, the silent-board trap Step 4 warned about, now from the devicetree side. zephyr,shell-uart points the shell at the same wire (an application that enables the shell needs no further directions), and zephyr,uart-mcumgr pre-answers where the MCU manager protocol would live if the product enables it, as Course 1 did. Not every chosen property our file doesn't set is an omission: zephyr,code-partition, zephyr,flash and zephyr,sram are already answered by the partition include, which is why including it beat re-typing them.
  • leds (gpio-leds) describes the one LED: node label led0, pin &gpio0 15, GPIO_ACTIVE_HIGH, the polarity from the spec table, stated here and nowhere else. Application code will ask for "the LED" and get this pin with this polarity already folded into the GPIO calls.
  • buttons (gpio-keys) is the same pattern for input: button0 on P0.24 with (GPIO_PULL_UP | GPIO_ACTIVE_LOW), the pull-up is in the description because the board fitted no external one, and a zephyr,code of INPUT_KEY_0, the key code the input subsystem reports for this button. The code comes from the input-event-codes header included at the top of the file.
  • aliases are the board's public API. led0 = &led0 and sw0 = &button0 are the names Zephyr samples and portable applications are written against, sw0 is the alias half the samples in the tree expect a board to provide. watchdog0 names the SoC's watchdog for the same reason. A board that describes an LED but forgets the alias has described hardware nobody can find by the standard name; Step 10's sample makes that failure loud on purpose.

Peripheral enablement: only what the board wires up

The bottom of the file flips SoC peripherals from disabled to okay, one deliberate node at a time:

  • &reg1 selects DC/DC regulator mode. A battery node takes the efficiency; the line costs nothing and the comment in the file says why it is there. (Regular engineers forget this node exists; on a coin-cell product it is measurable runtime.)
  • &gpiote and &gpio0/&gpio1 enable the GPIO machinery the LED and button hang off. Inside &gpio0, two teaching details: gpio-reserved-ranges = <0 2> fences off P0.00/P0.01: the 32.768 kHz crystal pins, so no future application can claim the clock's pins as GPIO by accident; and gpio-line-names labels the pins a human will probe, in pin order, so debug tooling can show TXD, LED and BUTTON instead of bare numbers.
  • &uart0 is the console made real: the UARTE compatible, status = "okay", current-speed = <115200> from the spec table, and the two pinctrl states from Step 5 attached as pinctrl-0/pinctrl-1 with their names. Miss the pinctrl properties and the UART exists but is connected to nothing, another member of the silent-failure family, and another reason Step 12 inspects the generated devicetree rather than trusting this file.

The configuration that is not here

Return to the fitted 32.768 kHz crystal. Nothing in this file mentions it, and no line in the defconfig sets the low-frequency clock source. That is correct, and verifiable: the clock driver's Kconfig in the pinned tree declares the source as a choice whose default is the crystal (CLOCK_CONTROL_NRF_K32SRC_XTAL). A board with a crystal is the assumed case. A board without one, several module boards omit it to save cost and area, must say so, with one defconfig line: CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y. Get that wrong on a crystal-less board and the firmware hangs early and silently, waiting for a clock that will never tick: the third member of the silent family. Fieldnode has the crystal, takes the default, and Step 12 will grep the generated .config to prove the default is what the build actually used, because "the default should apply" is a claim, and claims get checked.

Checkpoint: the file includes exactly three things before its own nodes; chosen answers console and shell; LED and button carry the spec table's pins and polarities; aliases led0/sw0 exist; &uart0 is enabled with pinctrl attached. Every one of those will be re-verified from generated output in Step 12.

Step 7: board.cmake: how tools reach the board

Why this step exists: building is only half the workflow; west flash and west debug have to know which host tool drives your debug probe and with what arguments. That knowledge is board-specific, it depends on the header you fitted and the probes you support, so it lives in the board folder, in board.cmake. When it is wrong, builds still pass; only flashing fails, usually at the worst moment.

Procedure:

  1. Create board.cmake in the board folder with the content below.
  2. Match each runner to the spec table: the debug header is wired for a J-Link, so J-Link carries arguments; the two Nordic-native runners are included because a Nordic DK's debug-out port or an nRF probe may be what is actually on the bench.

sample/boards/fieldnode/board.cmake:

# SPDX-License-Identifier: Apache-2.0

# The debug header on the board is wired for a SEGGER J-Link, so
# J-Link is the default runner; nrfutil / nrfjprog cover Nordic
# probes and DK debug-out ports.
board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000")

include(${ZEPHYR_BASE}/boards/common/nrfutil.board.cmake)
include(${ZEPHYR_BASE}/boards/common/nrfjprog.board.cmake)
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)
  • board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000") teaches the J-Link runner which silicon to talk to. The device string is J-Link's name for the chip family (not the Zephyr SoC name. Every tool has its own dialect, and this file is where the translation lives).
  • The include(...) lines pull in the shared runner definitions from the tree's boards/common/. You are declaring support, not implementing flashing: each include makes west flash --runner <name> work for that probe family.
  • The line that is not there: the DK's board.cmake passes the nrfutil runner an --ext-mem-config-file describing its QSPI flash, so the flasher knows how to reach external memory. Fieldnode has no external flash (the spec table said so in Step 0), so there is nothing to describe, including the DK's line would teach the flasher to look for a chip that does not exist. Board files fail by inheritance more than by invention; this file is three includes and one argument list because that is what this board has.

Checkpoint: the runner set matches the probes your bench actually owns, the J-Link device string names your chip in J-Link's dialect, and there is no configuration for hardware the board does not have.

Step 8: The platform file and the doc page: the board, described to tools and humans

Why this step exists: two consumers remain who are neither the compiler nor the flasher. Twister, Zephyr's test runner, discovers testable platforms from a per-target YAML file, without it, your board is invisible to the test infrastructure, and "does the tree still build for my board" stays a manual question. And humans, future you included, need the board's documentation page, which in-tree boards are required to carry in a fixed format. For an application board both files are technically optional. Write them anyway: they cost ten minutes now and are the difference between a board definition and a board product.

Procedure:

  1. Create fieldnode_nrf52840.yaml in the board folder (content below). The identifier must be the exact build target string. It is how Twister names the platform.
  2. Create doc/index.rst inside a doc/ subfolder of the board folder (content below). Keep it factual and short: it is generated from the same spec table as everything else, and the zephyr:board:: directive ties the page to the board by name.

sample/boards/fieldnode/fieldnode_nrf52840.yaml:

identifier: fieldnode/nrf52840
name: Fieldnode-NRF52840
type: mcu
arch: arm
ram: 256
flash: 1024
toolchain:
  - zephyr
  - gnuarmemb
supported:
  - gpio
vendor: fieldnode

Read it as a capability claim, and keep it an honest one: ram/flash are the SoC's sizes in KiB, toolchain lists the toolchains that can build for the target, and supported names the feature families Twister may schedule tests for. Ours claims only gpio, the one family this guide has actually wired and will actually verify. Claiming ble or usb_device because the silicon supports them, before the board definition and a test have earned it, is how platform files become fiction. Grow the list as the board proves itself.

sample/boards/fieldnode/doc/index.rst:

.. zephyr:board:: fieldnode

Overview
********

The Fieldnode is a battery-powered sensor node built around a bare
Nordic Semiconductor nRF52840 ARM Cortex-M4F SoC. It is the custom
board this repository's firmware ships on: no development-kit extras,
just the peripherals the product uses.

Hardware
********

Fieldnode has two external oscillators. The frequency of the slow
clock is 32.768 kHz. The frequency of the main clock is 32 MHz. There
is no external flash: the SoC's 1 MB of internal flash holds the
bootloader, two application slots and a storage partition.

Supported Features
==================

.. zephyr:board-supported-hw::

Connections and IOs
===================

LED
---

* LED (green) = P0.15

Push buttons
------------

* BUTTON = P0.24

Debug header
------------

* UART0 TX = P0.06, RX = P0.08, 115200 baud
* SWD: SWDIO / SWCLK / RESET for a SEGGER J-Link

Programming and Debugging
*************************

.. zephyr:board-supported-runners::

Applications for the ``fieldnode/nrf52840`` board configuration can
be built, flashed, and debugged in the usual way, using the J-Link
on the debug header:

.. code-block:: console

   $ west build -b fieldnode/nrf52840 path/to/app
   $ west flash

Debugging is the usual ``west debug`` with the same J-Link.

The two zephyr:board-supported-* directives are the upstream trick worth knowing: when this page is built as part of a documentation tree, those tables are generated from the board's own YAML and devicetree, so the published "supported features" can never drift from the files they describe. For an application board the page mostly serves your own team's docs, but it is in exactly the form an upstream submission requires, which matters the day this board (or the next one) is worth sharing.

Checkpoint: the YAML's identifier string equals the build target you have been typing, and the doc page's pin lists match the devicetree, because both were written from the spec table, not from each other.

Step 9: sysbuild/CMakeLists.txt: connect the board to the build

Why this step exists: your board now exists on disk. The build still cannot see it. Board roots are scanned from the SDK, from modules, and from roots somebody registers, and an application folder is not registered by default. Worse, under sysbuild (the build system NCS uses, which is why the Zigbee course's builds could assemble MCUboot plus an application in one command), the board question is settled before your application's CMakeLists.txt is ever read, so the usual "set it in the app" advice arrives too late. This is the step everyone hits, and the failure is total and unambiguous:

No board named 'fieldnode' found. Did you mean:


Run 'west boards' for the full list.
CMake Error at .../cmake/modules/boards.cmake:221 (message):
  Invalid BOARD; see above.

That output is quoted verbatim: it is what this project printed on its first build attempt, with Steps 1 to 8 complete and only this step missing. The fix is a four-line file in a folder most applications do not have yet.

Procedure:

  1. Create the folder sample/sysbuild/.
  2. Create CMakeLists.txt inside it with the content below.
  3. Understand the one line that matters before you build: list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..), CMAKE_CURRENT_SOURCE_DIR is sample/sysbuild/, so .. is the application folder, and the application folder is a valid board root because it contains boards/. The root is the folder that contains boards, never the board folder itself and never the boards folder; getting that level wrong reproduces the error above with extra confusion.

sample/sysbuild/CMakeLists.txt:

# SPDX-License-Identifier: Apache-2.0

# The board definition for this product lives in this application's
# own boards/ folder. Sysbuild resolves the board before the
# application image itself is loaded, so the board root has to be
# registered here, at the sysbuild level — putting BOARD_ROOT in the
# application's CMakeLists.txt would be too late and the build would
# stop with "No board named 'fieldnode' found".
list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..)

# Finds the sysbuild project and includes it with the new configuration
find_package(Sysbuild REQUIRED HINTS $ENV{ZEPHYR_BASE})

project(sysbuild LANGUAGES)

The alternative, passing -- -DBOARD_ROOT=<path> on every west build command line, works, and the board-porting documentation describes it, but it makes the board's existence a piece of tribal knowledge every developer and CI job must remember. The sysbuild/CMakeLists.txt makes it a property of the project: clone the repo, build, board found. Properties of the project belong in the project.

Checkpoint: sample/sysbuild/CMakeLists.txt exists, registers the application folder (not the board folder) as a board root, and does so before its find_package(Sysbuild ...) line.

Step 10: The bring-up application: a test that can only pass on the right board

Why this step exists: a board definition is not verified by existing. It is verified by an application behaving correctly on it. That application should be the smallest thing that exercises the board's claims: the console (Step 4 + Step 6's chosen), the LED and the button (Step 6's nodes and aliases). Anything bigger mixes application bugs into board bring-up; anything smaller lets board mistakes slip through silently. This one is deliberately written so that several whole classes of board error fail at build time, the cheapest place any failure will ever happen.

Procedure:

  1. Create the application's CMakeLists.txt (below), the ordinary Zephyr application entry point, unchanged from every application you have ever written. Notice what it does not contain: no board knowledge at all. That absence is the design working.
  2. Create prj.conf (below) and resist filling it. Read the paragraph after it.
  3. Create src/main.c (below). The tour after the file explains the tripwires.

sample/CMakeLists.txt:

# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(fieldnode_bringup)

target_sources(app PRIVATE src/main.c)

sample/prj.conf:

# Fieldnode bring-up sample configuration.
#
# Deliberately almost empty: GPIO, the UART driver and the console
# are already enabled by the *board* defconfig
# (boards/fieldnode/fieldnode_nrf52840_defconfig), because every
# application for this board wants them. prj.conf is where
# application choices would go — and this application has none.

Yes, it is comments only, and that is a board-definition success being demonstrated, not laziness. The application uses GPIO and a UART console, and enables neither: both came from the board defconfig in Step 4, because they are properties of the board. If you find a board whose every application must re-enable the same five symbols, the board files are unfinished. prj.conf is where application choices live. This application has none.

sample/src/main.c:

/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Fieldnode bring-up check: the smallest application that proves a
 * board definition works. It uses nothing but the board's standard
 * aliases — led0 and sw0 — so if the board files describe the LED or
 * the button wrong, this file fails to build or misbehaves loudly,
 * which is exactly what a bring-up test is for.
 *
 * Expected behaviour on the desk: the banner prints on the UART
 * console, the LED blinks twice a second, and every button press
 * and release prints a line.
 */

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/sys/printk.h>

/* Fails the build if the board has no led0 / sw0 alias — on purpose. */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios);

int main(void)
{
    int ret;
    int was_pressed = 0;

    printk("\nFieldnode bring-up, board target: %s\n", CONFIG_BOARD_TARGET);

    if (!gpio_is_ready_dt(&led) || !gpio_is_ready_dt(&button)) {
        printk("GPIO is not ready — check the board's gpio nodes\n");
        return 0;
    }

    ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
    if (ret == 0) {
        ret = gpio_pin_configure_dt(&button, GPIO_INPUT);
    }
    if (ret < 0) {
        printk("GPIO configure failed (%d)\n", ret);
        return 0;
    }

    printk("Blinking led0, watching sw0. Press the button.\n");

    while (1) {
        int pressed;

        gpio_pin_toggle_dt(&led);

        pressed = gpio_pin_get_dt(&button);
        if (pressed != was_pressed) {
            printk("sw0 %s\n", pressed ? "pressed" : "released");
            was_pressed = pressed;
        }

        k_sleep(K_MSEC(250));
    }
    return 0;
}

The tripwires, in order:

  • GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios) is a compile-time assertion wearing a trench coat. If the board's devicetree lacks the led0 alias, or the node behind it has no gpios property, the macro chain cannot resolve and the build stops with a devicetree error naming the alias. Board mistake → build failure, before any hardware is involved. Same for sw0. Portable applications are written against aliases for their own convenience; here, the aliases double as the board's exam.
  • CONFIG_BOARD_TARGET in the banner prints the exact target string from the generated configuration. The board identifying itself, in its own output. When Step 13's desk test runs, that line is the first thing you read: it proves which board definition the running firmware was built from, which matters enormously on the day two similar boards are on the bench.
  • The behaviour is the spec table, executed. LED toggles every 250 ms; the button is polled on the same loop and every press and release prints a line. Polarity errors cannot hide: wire the LED's polarity wrong in the devicetree and the blink inverts against the desk observation; get the button's pull or polarity wrong and the prints fire inverted or not at all. Step 13 turns each of those into a named expectation.

Checkpoint: the application refers to hardware only through the led0/sw0 aliases, prints its board target at boot, and its prj.conf adds nothing the board should have provided.

Step 11: Build for your board

Why this step exists: ten files of claims meet reality. The build is the first full traversal of the diagram at the top of this guide (discovery, target resolution, both merges), and it either produces firmware for fieldnode/nrf52840 or tells you which stage rejected your work.

Procedure:

  1. Stand in the folder that contains guides/ (the project root), with the course's pinned toolchain environment active (Chapter 1 of the Zigbee course sets this up; the same terminal you have built every lab in).

  2. Build the bring-up application for the new target:

    west build -p always -b fieldnode/nrf52840 guides/fieldnode/sample

    -p always forces a pristine build, for a first build of a new board, always pristine: a reused build folder can carry a cached board from an earlier experiment and turn this step into a liar.

  3. Read the end of the output. A successful build of the files on this page ends with the memory report and a line naming the board the files were generated for (your paths will differ; the numbers and the board name are the real output of this project's build):

    Memory region         Used Size  Region Size  %age Used
               FLASH:       23048 B         1 MB      2.20%
                 RAM:        6080 B       256 KB      2.32%
    Generating files from .../build/sample/zephyr/zephyr.elf for board: fieldnode/nrf52840

Checkpoints while it runs:

  • If configure stops immediately with No board named 'fieldnode' found, do not touch the board files; they were never read. Go to "If it breaks", first entry: this is Step 9's failure, and it is the expected first-build experience (this project hit it too).
  • If configure runs long and fails inside Kconfig with an error naming a SOC_... symbol, the problem is in the Step 1 / Step 2 pair: a variant string where an SoC name belongs, or a select for a variant that does not exist. "If it breaks", second entry.
  • If the build compiles and links, the definition is structurally sound. It is not yet verified. A build can succeed while quietly using files you did not write. That is what the next step exists to exclude.

Step 12: Prove it is really your board

Why this step exists: "it built" proves the build system found a coherent board definition. These four checks prove it found yours. Each one reads a generated artifact, not a source file, following the rule from the start of this guide: verify the merge, not the ingredients. Run all four; together they take two minutes.

Check 1, the build consumed your folder. The build's CMake cache records the exact directory the board was taken from:

grep BOARD_DIR build/sample/CMakeCache.txt
# BOARD_DIR:PATH=.../guides/fieldnode/sample/boards/fieldnode

The path must end in your sample/boards/fieldnode. This single line rules out the entire class of "it silently used some other definition" accidents.

Check 2, discovery sees the board. west boards lists the boards of the workspace, and prints nothing for Fieldnode, which surprises everyone the first time. It is not a failure: the workspace scan does not include your application folder. The script the build system itself runs for discovery is in the tree, and it accepts extra roots; point it at yours:

python3 $ZEPHYR_BASE/scripts/list_boards.py \
  --board-root $ZEPHYR_BASE \
  --board-root guides/fieldnode/sample \
  --soc-root $ZEPHYR_BASE \
  --board fieldnode
# fieldnode

It answers with the board's name, from the same code path CMake used in Step 11. (Leave out the --soc-root and the script complains that the SoC is not found. The standalone script needs to be told where SoCs live, which the full build knows implicitly. That error looks alarming and means nothing about your board.) The day this board moves into a module in your workspace, plain west boards lists it too, and this check becomes a formality.

Check 3, the generated .config tells the Kconfig story. Every Kconfig claim from Steps 2 to 4 is testable in one grep:

grep -E "CONFIG_BOARD=|CONFIG_BOARD_TARGET|SOC_NRF52840|K32SRC_XTAL|HW_STACK" \
  build/sample/zephyr/.config
# CONFIG_HW_STACK_PROTECTION=y
# CONFIG_BOARD="fieldnode"
# CONFIG_BOARD_TARGET="fieldnode/nrf52840"
# CONFIG_BOARD_FIELDNODE=y
# CONFIG_BOARD_FIELDNODE_NRF52840=y
# CONFIG_SOC_NRF52840_QIAA=y
# CONFIG_CLOCK_CONTROL_NRF_K32SRC_XTAL=y

Read the seven lines as the receipts for three different steps: BOARD_FIELDNODE and its per-target sibling were generated from board.yml (Step 1); SOC_NRF52840_QIAA arrived via Step 2's select; HW_STACK_PROTECTION=y was never assigned anywhere. It is Step 3's default, firing; and K32SRC_XTAL=y closes the Step 6 cliffhanger: the crystal default really is what the build used, proven from output rather than trusted from documentation. If any expected line is missing, the .config, not your source files, is where the investigation starts, because a Kconfig line whose dependencies are unmet vanishes silently from the output while looking perfectly healthy in the source.

Check 4, the generated devicetree kept your receipts. The build writes the fully merged devicetree to build/sample/zephyr/zephyr.dts, with a comment on nearly every line naming the file and line it came from. Inspect the three claims that matter most:

grep -n "zephyr,console" build/sample/zephyr/zephyr.dts
# zephyr,console = &uart0;   /* in .../boards/fieldnode/fieldnode_nrf52840.dts:24 */

grep -n -A2 "led_0" build/sample/zephyr/zephyr.dts
# led0: led_0 {
#     gpios = < &gpio0 0xf 0x0 >;   /* in .../boards/fieldnode/fieldnode_nrf52840.dts:33 */

grep -n "zephyr,code-partition" build/sample/zephyr/zephyr.dts
# zephyr,code-partition = &slot0_partition;

The console line's comment names your .dts at line 24, the exact line where you wrote it. The LED's gpios reads < &gpio0 0xf 0x0 >: pin 0xf = 15, flags 0. The devicetree compiler has already folded your GPIO_ACTIVE_HIGH into the raw flag word, which is what "the description was understood" looks like in generated form. And the code partition resolves to the slot0_partition (at 0x00C000, 472 KiB) that arrived via the partition include. When a desk test misbehaves later, this file is where you will come to see what the firmware actually believes about the hardware. It is the single most useful debugging artifact a board author owns.

Step 13: The desk test: run it on hardware

Why this step exists: compile-level verification proves the definition is coherent and was consumed. Only running firmware proves the description matches the metal, pins, polarities and all. If your Fieldnode hardware exists, it is the target. If it does not exist yet, there is a legitimate stand-in, and it is probably already on your desk.

Procedure, on Fieldnode hardware:

  1. Connect the J-Link to the debug header and a terminal to the UART (115200, 8N1, the values from the spec table and the defconfig).

  2. Flash from the build folder: west flash. The runners come from Step 7's board.cmake; if several probe families are attached, say which: west flash --runner jlink.

  3. Reset the board and watch the console. What you should see is fixed by main.c, word for word:

    Fieldnode bring-up, board target: fieldnode/nrf52840
    Blinking led0, watching sw0. Press the button.
  4. Confirm the LED toggles steadily (the loop toggles every 250 ms). Press and release the button; each edge prints its line:

    sw0 pressed
    sw0 released

Procedure: the DK as a stand-in. No Fieldnode PCB yet? Flash the same fieldnode build to an nRF52840 DK (west flash works. The DK's onboard debugger is a J-Link, and the chip is the same). Then compare against expectations derived from the two boards' files, line by line:

  • Banner and console: exact. Fieldnode's UART0 pins (TX P0.06, RX P0.08) are the very pins the DK wires to its interface MCU, so the console works unmodified. First line must read board target: fieldnode/nrf52840, on DK hardware. That is the point of the exercise: the firmware believes it is on a Fieldnode, because the board files told it so.
  • Button: exact. The DK's BUTTON3 is P0.24, active-low with a pull-up, the same pin and polarity as Fieldnode's button, by the happy accident of the spec table borrowing a sane pin. Press BUTTON3; the sw0 prints fire.
  • LED: inverted, and that is correct. Fieldnode's LED is P0.15, active-high. On the DK, P0.15 is LED3, wired active-low. The firmware drives the pin exactly as Fieldnode's files describe; the DK's LED therefore lights when the firmware "turns it off" and vice versa. Nothing is broken: you are watching a polarity disagreement between two board descriptions, visible in light. Fix it only by changing the board file, never by patching the application.

Verification boundary, stated plainly: everything up to this step was executed and verified in the pinned environment while writing this guide. The files were created, the build ran green, and every quoted output above (discovery, .config, generated devicetree, memory report) is real output from those files. The console behaviour in this step is the expected behaviour of that verified build, derived from its source and from the two boards' published pin tables; run it on your bench and the board will grade the definition itself.

If it breaks

Work the symptom, in this order. The list is sorted by how often each failure actually happens, and the first two together account for most first-board afternoons.

  1. No board named 'fieldnode' found at configure time. The board files were never read. Causes, in order: sample/sysbuild/CMakeLists.txt is missing (Step 9 skipped); or its BOARD_ROOT points at the wrong level, at the boards/ folder or the board folder instead of the folder containing boards/; or the list(APPEND BOARD_ROOT ...) sits after the find_package(Sysbuild ...) line, too late to matter. Nothing inside boards/fieldnode/ can cause or fix this error.
  2. Discovery or Kconfig errors naming an SoC. SoC 'nrf52840_qiaa' is not found-family errors mean the variant string wandered into a slot that wants the SoC name, almost always board.yml's socs: list (Step 1). The mirror-image error, a configure-time Kconfig complaint naming a SOC_NRF52840_... symbol, means Step 2 selected a variant that does not exist for this SoC (or the if BOARD_..._NRF52840 guard names a target the board does not have). The rule that prevents both: SoC name in board.yml, variant in Kconfig, package include in the devicetree, one fact, three homes, no improvisation.
  3. A defconfig line you wrote is missing from the built .config. Do not debug the file you wrote; debug the generated one (Step 12, Check 3). Kconfig drops assignments whose dependencies are unmet without an error. A CONFIG_UART_CONSOLE=y in a build where the serial subsystem is off simply evaporates. Find the symbol's depends on in the tree, satisfy it, rebuild pristine. This failure mode once cost the Zigbee course a subsystem that "was enabled" for an entire chapter draft; the .config grep is now Step 12 by reflex.
  4. It builds and flashes, and the terminal is silent. The silent family, work the checklist from the description outward: is zephyr,console present in the generated devicetree (Check 4)? Are CONFIG_CONSOLE/CONFIG_UART_CONSOLE in the generated .config? Is &uart0 status = "okay" with its pinctrl properties. An enabled UART with no pinctrl is wired to nothing. Then leave the files and check the physics: TX and RX crossed at the header (the board's TX goes to the adapter's RX), and the terminal at 115200.
  5. Console works; the LED or button lies. Polarity and pins are devicetree facts: LED inverted → the GPIO_ACTIVE_HIGH/LOW on the led0 node disagrees with how your LED is wired; button reported pressed when released (or a stream of edges at boot) → the button's pull-up flag or active level is wrong; nothing responds at all → the pin number. In all three cases, compare the generated devicetree's raw values (Check 4, 0xf for pin 15) against the schematic before touching code: the application cannot be wrong about a pin it never named.
  6. The firmware hangs before the banner, on a board with no 32 kHz crystal. That is Step 6's warning arriving on schedule: the tree defaults the low-frequency clock to a crystal that is not there. Add CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y to the board defconfig and rebuild. (Fieldnode fits the crystal, so this entry is for your next board, the one where purchasing saved the crystal away.)

You are done when

  • All ten board files exist under sample/boards/fieldnode/, named exactly as the layout in the introduction shows
  • west build -p always -b fieldnode/nrf52840 completes green for the bring-up application
  • BOARD_DIR in the build's CMake cache points at your boards/fieldnode folder
  • The generated .config shows CONFIG_BOARD_FIELDNODE=y, the QIAA variant selected, CONFIG_CLOCK_CONTROL_NRF_K32SRC_XTAL=y and CONFIG_HW_STACK_PROTECTION=y
  • The generated zephyr.dts shows your console chosen, your LED pin and the slot0 code partition, with source comments naming your .dts
  • On hardware (or the DK stand-in): the banner prints the fieldnode/nrf52840 target, the LED blinks, and the button prints its press and release

Where the board goes from here

A board definition is a living file, but it grows in the patterns you have already used:

  • More hardware is more of Step 5 and Step 6: an I2C sensor gets pinctrl groups and a bus node, and then the out-of-tree driver from Guide 1 drops onto this board unchanged, because the driver never knew which board it was on. That is the two guides' shared point, from opposite directions.
  • A second hardware revision keeps the same folder: the hardware model has a revision mechanism (board.yml grows revision entries; per-revision devicetree and config files sit beside the ones you wrote), so fieldnode/nrf52840 stays the target while the description tracks the PCB spin.
  • A second SoC or a shared platform is the graduation signal: when two products share the board, or a second application needs it, move boards/ into a small Zephyr module of its own, Guide 1's packaging, applied to a board instead of a driver. The files move verbatim; west boards starts listing your board in the workspace; and the doc page and Twister file you wrote in Step 8 are already in the shape an upstream submission would need.

The deeper lesson is the one Step 12 taught: a board file is not finished when it is written, or even when it builds. It is finished when the generated artifacts (the merged devicetree, the merged configuration, the running banner that names its own target), have been read and found to say what the schematic says. Write boards that way, and the day your PCB lands, bring-up is an afternoon of confirmations instead of a week of mysteries.

← All guides

GuideZephyr · Devicetree~3 hours, following along

Zephyr devicetree, explained by wiring one sensor end to end

Zephyr devicetree, explained by wiring one sensor end to end

Ask a room of Zephyr developers which part of the system they understand least and devicetree wins every time. Not because it is hard, exactly, but because every tutorial teaches it in fragments: an overlay snippet here, a macro there, a wall of generated filenames nobody opens. So the description stays magic. You copy an overlay from a forum, it works or it doesn't, and when it doesn't you have no model of what to check, only superstitions to rearrange.

This guide builds the model by following one sensor through every layer its description passes through. The sensor is a BME280 (temperature, pressure, humidity) on a wire to an nRF52840 DK. By the end, that sensor is described exactly once, in one small file, and you will have watched the description do four separate jobs: select the driver, configure the build, hand the application its facts, and fail the build loudly when it goes missing. Every layer gets opened and read, not gestured at: the board's own devicetree, your overlay, the merged result in the build folder, the generated header the C compiler actually sees, and the generated .config.

The application at the end contains no I2C address, no bus name, and no part number as a string. That is not a style preference. It is the proof that the description, and only the description, carries the hardware facts, so the day the sensor moves to a different bus, or the whole application moves to a different board (Guide 2's territory), the code does not change at all.

By the end you will have:

  • A four-file application (CMake, one Kconfig line, one overlay, one C file) that reads a real sensor once a second, every hardware fact sourced from the devicetree.
  • A working map of the description pipeline: board .dts and pinctrl, overlay merge, zephyr.dts, devicetree_generated.h, and the .config the merge steers.
  • Three proofs you can run on any project, not just this one, that the description you wrote is the description the build used.
  • A deliberate failure, induced and repaired, that teaches you how devicetree problems announce themselves (and how to make them announce themselves better, in words you wrote).
  • A desk test that grades the wiring and the address strap against the description, including the classic wrong-address experiment, fixed in the one place such a fix belongs.

What you need

  • An nRF52840 DK. Any Zephyr board with an exposed I2C bus works; the overlay and the wiring table are the only board-specific parts.
  • A BME280 breakout board (the ubiquitous purple or blue module with four or six pins) and four jumper wires. If your module is a BMP280, stop and swap it if you can: the BMP280 is the same package with no humidity element, and one of this guide's desk tests uses humidity. It will still build and read temperature and pressure, and the "If it breaks" table explains the symptom that gives it away.
  • The pinned workspace from this site's other guides: nRF Connect SDK v3.4.1 (Zephyr 4.4.2) with the toolchain on your path, and a terminal that can run west. Guide 1 sets this up step by step if you are starting cold.
  • About three hours, following along: an hour of reading descriptions, an hour of building and proving, an hour of wiring and testing. The sensor itself is a five-minute wire-up; the time goes on learning to read what the build did with your description, a skill that pays out on every Zephyr project afterwards.

Meet the pieces: what a devicetree is, and is not

A devicetree is a description of hardware written for the build, in a small language of nodes (things: buses, chips, pins) and properties (facts about the things: addresses, speeds, which pins). It is not code. Nothing in it executes. It is compiled, by a tool called dtc and then by Zephyr's own Python tooling, into two things your build consumes: a set of C macros (so code can ask questions like "what address did the description give this node?") and a set of facts Kconfig can test (so the build can ask "is there an enabled node of this kind?" and pull in the matching driver).

Three audiences read the description, and keeping them separate is the whole mental model:

  1. The build system reads it to select code. A driver whose Kconfig says "default on, when an enabled node with my compatible exists" is selected by the description alone. You will watch this happen: this guide's prj.conf never names the sensor driver, and the driver is in the build anyway.
  2. Drivers read it at compile time for their instance data: which bus they sit on, their address, their pins. The BME280 driver knows how to be a BME280; the description tells it where this one lives.
  3. Your application reads it through macros (DT_ALIAS, DEVICE_DT_GET, DT_REG_ADDR, ...), which are all resolved by the preprocessor. By the time your code runs, every devicetree question has already been answered. There is no devicetree at runtime, only its consequences.

And three files do the describing, in layers:

  • The SoC description (Nordic's, in the SDK) names every peripheral the silicon has, at its hardware address, almost all of it status = "disabled". You will rarely open it, but it is the bottom of every merge.
  • The board description (the DK's .dts plus its pinctrl file) enables what the board wires out: this connector is live, on these pins. Step 1 reads it, because it has already done half your work.
  • Your overlay (app.overlay, in the application folder) is applied last and wins: it adds your sensor, on your board, at your address. Overlays extend and amend; a property you restate replaces the earlier value, a node you add joins the tree.

Two special nodes ride along in almost every tree, and this series uses both before it is done. aliases you will meet in Step 3: name-to-node pointers that let applications speak of "the sensor" instead of a path. chosen is its system-level sibling: pointers every subsystem agrees to consult, like zephyr,console (where printk goes), zephyr,shell-uart (where the shell lives; the next guide's coordinator sets it in its overlay, exactly the mechanism you are learning here), and zephyr,flash (which flash device holds the settings). When a subsystem seems to know something you never configured, a chosen pointer is almost always how it knows.

Your schematic
BME280 wired to the Arduino I2C header

Board devicetree
nrf52840dk.dts: the bus, enabled, on P0.26/P0.27

app.overlay
one child node + one alias

Merged devicetree
build/zephyr/zephyr.dts, every line sourced

Generated header
devicetree_generated.h: the macros

Driver selection
CONFIG_BME280=y with no prj.conf line

Application
DEVICE_DT_GET on the alias, nothing hardcoded

One vocabulary warning before the steps, because it causes real confusion: a node's label (bme280: before the node name, a handle other files use to refer to it, written &bme280) is not a name (bme280@76, the node's identity, where 76 is its unit address) and neither is a compatible ("bosch,bme280", the string that marries the node to a binding and a driver). Three different jobs. The steps use each one where it belongs.

Before you start: the application skeleton

This guide keeps its project in a top-level guides/devicetree-sensor/ folder next to the other guide projects, outside the SDK tree:

mkdir -p guides/devicetree-sensor/src

Four files will live there: CMakeLists.txt, prj.conf, app.overlay, src/main.c. Create the first two now; the overlay and the C file get their own steps, because they are the guide.

CMakeLists.txt is the standard application form, nothing devicetree-specific:

# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(devicetree_sensor)

target_sources(app PRIVATE src/main.c)

prj.conf is one real line and a comment that is doing teaching work:

# The sensor subsystem. That is the whole file, and the absence below is
# the lesson of this guide: there is no CONFIG_BME280 line here, and no
# CONFIG_I2C line either. The BME280 driver is pulled in by the
# devicetree node in app.overlay alone (its Kconfig defaults to y when
# an enabled bosch,bme280 node exists), and the I2C controller driver
# comes from the board's own devicetree the same way. Step 6 of the
# guide proves both from the generated .config.
# (Step numbering: the proof lives in Step 6.)
CONFIG_SENSOR=y

Read that comment again, because Step 6 cashes it in: there is deliberately no CONFIG_BME280 and no CONFIG_I2C in this application. If those symbols end up set in the build (and the build needs both), the description put them there. That is the experiment this whole guide is rigged to run.

Checkpoint: the folder holds CMakeLists.txt and prj.conf, and you can say, in one sentence, what is not in prj.conf and why its absence is a test and not an oversight.

Step 1: Read the board's description before writing yours

Your overlay amends the board's description, so read the board first. In the SDK tree, open zephyr/boards/nordic/nrf52840dk/nrf52840dk_nrf52840.dts and find the I2C section. The DK labels its Arduino-header I2C bus arduino_i2c, and the block reads:

arduino_i2c: &i2c0 {
    compatible = "nordic,nrf-twi";
    status = "okay";
    pinctrl-0 = <&i2c0_default>;
    pinctrl-1 = <&i2c0_sleep>;
    pinctrl-names = "default", "sleep";
};

Four facts in six lines, each worth naming:

  1. arduino_i2c: is a label on the SoC's i2c0 node. The SoC description defined the controller (at its hardware address, disabled). This block refers to that same node by its label (&i2c0) and amends it. Your overlay will refer to it by its label, &arduino_i2c. Same node, three descriptions deep.
  2. compatible is restated as "nordic,nrf-twi". The SoC's generic description offered a choice of hardware blocks (the nRF52 has combined TWI/TWIM peripherals); the board picks the two-wire-master flavour. Even Nordic amends descriptions at board level. You are not doing anything unusual; you are doing what every layer does.
  3. status = "okay" is the enable. A peripheral the board does not wire out stays disabled and costs no code and no power. This bus is wired to a header you can touch, so the board enables it. (Your sensor node, when you add it, will be enabled too: a node with no status property at all counts as okay, which is why your overlay will not need the line. The distinction matters in Step 6.)
  4. The pins live somewhere else entirely: pinctrl-0 = <&i2c0_default> is a phandle, a pointer to a pin-control group defined in the companion file nrf52840dk_nrf52840-pinctrl.dtsi:
i2c0_default: i2c0_default {
    group1 {
        psels = <NRF_PSEL(TWIM_SDA, 0, 26)>,
            <NRF_PSEL(TWIM_SCL, 0, 27)>;
    };
};

SDA is P0.26, SCL is P0.27. That is a fact about the DK's copper, recorded once, in the board's files. Your application will never mention these pins, your overlay will not repeat them, and Step 8's wiring table comes straight from this block. When people say "the board file knows the board", this is the entire trick: the facts are written down once, in the description layer, and every consumer reads them from there.

Checkpoint: without looking, name the bus label your overlay will extend, its two pins, and the file each fact came from. If you cannot, reread this step; everything after it leans on these three facts.

Step 2: The binding is the schema

Before describing a node, know what a legal description of it looks like. Every node with a compatible is checked against a binding: a YAML file that declares which properties that kind of node may (and must) carry. The BME280's binding is zephyr/dts/bindings/sensor/bosch,bme280-i2c.yaml, and it is short enough to read whole:

description: BME280 integrated environmental sensor

compatible: "bosch,bme280"

include: [sensor-device.yaml, i2c-device.yaml]

The power is in the includes. i2c-device.yaml is the shared contract for anything that sits on an I2C bus, and it contributes the one property this guide cares most about: reg, required, the device's address on its bus. sensor-device.yaml contributes the sensor-common properties. So a legal BME280 node is a child of an I2C controller node, carrying compatible = "bosch,bme280" and a reg. Nothing else is required. The driver adds its own optional properties in the full binding (sampling choices and the like); the guide's node needs none of them, because the defaults are sane and the guide is about the description, not about tuning a weather sensor.

The marriage works both ways, and Step 6 proves the second direction: the driver's Kconfig (in zephyr/drivers/sensor/bosch/bme280/Kconfig) opens with:

menuconfig BME280
    bool "BME280/BMP280 sensor"
    default y
    depends on DT_HAS_BOSCH_BME280_ENABLED
    select I2C if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),i2c)

Read it as a sentence: this driver is on by default, provided the devicetree contains an enabled node whose compatible is bosch,bme280; if that node sits on an I2C bus, also select the I2C subsystem. DT_HAS_BOSCH_BME280_ENABLED is a symbol the build generates from your description. This one depends on is why the experiment in prj.conf works: describe the part, and the build selects the driver and its bus to match. Description first, code follows.

Checkpoint: say which file decides that reg is required (the binding, via its include), and which decides the driver defaults on (the driver's Kconfig, via its depends on). Two files, two directions, one compatible string joining them.

Step 3: Write the overlay: one node, one alias

Create app.overlay in the application folder. The build applies a file with exactly this name automatically, no registration anywhere:

/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Describe the sensor to the build. This file is the entire hardware
 * description the application needs: which bus, which address, which
 * part. The C code never repeats any of it.
 */

/*
 * The board file already enables the Arduino header's I2C bus
 * (&arduino_i2c is the nRF52840 DK's label for its i2c0 node, pins
 * SDA P0.26 / SCL P0.27) and attaches its pin control. All the overlay
 * adds is the child node: one device, described once.
 *
 * The address is the breakout's strapped address. Most BME280
 * breakouts ship strapped for 0x76 (SDO wired to ground); a board with
 * SDO pulled up answers at 0x77 instead. If the finished build reports
 * the sensor "not ready", this number is the first suspect, and the
 * fix belongs here, in the description, not in the code.
 */
&arduino_i2c {
    bme280: bme280@76 {
        compatible = "bosch,bme280";
        reg = <0x76>;
    };
};

/*
 * A name the application can use. Code that refers to the alias never
 * learns which bus or board the sensor ended up on, so moving the
 * sensor to another bus, or the whole application to another board,
 * changes this file and nothing else.
 */
&{/} {
    aliases {
        env-sensor = &bme280;
    };
};

Line by line, because every line is a concept:

  • &arduino_i2c { ... }; opens the board's bus node for amendment. You are not redefining the bus (the board did that, Step 1); you are adding a child to it. Anything written inside lands on that node in the merged tree.
  • bme280: bme280@76 { ... }; is the child node. bme280: is its label, the handle &bme280 that other descriptions (and your alias, below) use to point at it. bme280@76 is its name: bme280 is the generic node name for the part, and @76 is its unit address, which by devicetree convention repeats the first value of reg. Keep them equal; the tools check that they agree, and a disagreement is the description arguing with itself.
  • compatible = "bosch,bme280"; is the join key from Step 2. It selects the binding that validates this node and the driver that will serve it. Spell it wrong and both go missing, silently (Step 7 shows how the guide's application defends against exactly that).
  • reg = <0x76>; is the address, the one hardware fact the bus needs. The angle brackets are a cell array: devicetree values are 32-bit cells, and <0x76> is a one-cell array holding the address.
  • There is no status line. A node with no status property is enabled by default; status is how you disable things (or enable what a lower layer disabled). Beginners cargo-cult status = "okay" onto every node; now you know it is only load-bearing when something below you said disabled.
  • The second block adds an alias. aliases is a node of phandles: name-to-node pointers the whole system agrees on (led0, sw0, and friends are how boards speak to applications). env-sensor = &bme280; gives your sensor such a name. The application in Step 4 will use only the alias, which is what buys the portability the introduction promised: move the sensor to i2c1 on the next board and the application changes zero lines, because the alias still resolves.

One question worth answering before it bites someone: why is the sensor a child of the bus node at all, instead of a free-standing node with a property saying "my bus is i2c0"? Because the tree is the wiring. Parent-child in devicetree means "sits on, is powered by, is reached through". The build derives DT_BUS() of your node from its parent, the driver finds its bus the same way, and the generated header in Step 6 will name your node /soc/i2c@40003000/bme280@76: a path that reads like the schematic.

Checkpoint: cover the file and reconstruct it from the concepts: parent by label, child with label + name + unit address, compatible, reg, alias. If a line's job is fuzzy, that line is the one to reread.

Where overlays live

One refinement, because you will meet it in real projects within the week. app.overlay is not the only filename the build hunts for. An application folder may also carry boards/<board>.overlay (for instance boards/nrf52840dk_nrf52840.overlay), applied for that board only, and shields and configuration fragments add their own. All the overlay files the build finds are merged, in a defined order, on top of the board description, and where two layers restate the same property the later one wins. This project needs only the one file, but when you inherit a project with three, do not guess which layer set the fact you are staring at: the merged zephyr.dts of Step 6 prints the winning file and line beside every property. The description keeps its own receipts; your job is to ask for them.

Step 4: The application: three devicetree questions, answered at compile time

Create src/main.c:

/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Read a BME280 that the devicetree describes.
 *
 * The application names exactly one thing from the hardware: the
 * env-sensor alias written in app.overlay. The bus, the address and
 * the part itself all reach this file as generated facts. Search this
 * file for 0x76, for i2c0, for "bme280" spelled as a string: the only
 * matches are in comments and in the devicetree macro arguments. That
 * is the layering the devicetree exists to give you.
 */

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/sys/printk.h>

/* The alias is the only hardware name the application uses. */
#define SENSOR_NODE DT_ALIAS(env_sensor)

/*
 * Fail the build, in plain words, if the description is missing.
 * Without an enabled bosch,bme280 node there is nothing to read, and
 * "the build went red with a sentence you wrote" beats "the board
 * booted and the sensor was silently absent" every single time.
 * Delete or rename app.overlay and rebuild to watch this fire.
 */
#if DT_NUM_INST_STATUS_OKAY(bosch_bme280) == 0
#error "No enabled bosch,bme280 node in the devicetree. Is app.overlay in the application folder, and is its node status okay?"
#endif

static const struct device *const sensor = DEVICE_DT_GET(SENSOR_NODE);

/*
 * Print a sensor_value as fixed point with three decimals, spelled
 * out by hand: a sensor_value is an integer part plus a micro part
 * that shares its sign, and the naive "%d.%06d" prints a reading of
 * minus half a degree as "0.-500000". Converting to milli first keeps
 * the sign in exactly one place.
 */
static void print_reading(const char *label, const struct sensor_value *val,
              const char *unit)
{
    int32_t milli = val->val1 * 1000 + val->val2 / 1000;
    int32_t whole = milli / 1000;
    int32_t frac = milli - whole * 1000;

    if (frac < 0) {
        frac = -frac;
    }
    printk("%s %s%d.%03d %s\n", label,
           (milli < 0 && whole == 0) ? "-" : "", whole, frac, unit);
}

int main(void)
{
    struct sensor_value temp, press, humidity;

    /*
     * Everything on this line was read out of the generated
     * devicetree at compile time: the node's path, its reg value,
     * the name of the bus node it hangs from. Change app.overlay,
     * rebuild, and this line changes with it. Nothing was typed here.
     */
    printk("Sensor node: %s\n", DT_NODE_PATH(SENSOR_NODE));
    printk("I2C address: 0x%02x, on bus node %s\n",
           DT_REG_ADDR(SENSOR_NODE), DT_NODE_PATH(DT_BUS(SENSOR_NODE)));

    if (!device_is_ready(sensor)) {
        printk("Sensor %s is not ready. Check the wiring first, "
               "then the strapped address against reg in app.overlay.\n",
               sensor->name);
        return 0;
    }

    printk("Reading %s once a second. Breathe on it.\n", sensor->name);

    while (1) {
        if (sensor_sample_fetch(sensor) == 0) {
            sensor_channel_get(sensor, SENSOR_CHAN_AMBIENT_TEMP, &temp);
            sensor_channel_get(sensor, SENSOR_CHAN_PRESS, &press);
            sensor_channel_get(sensor, SENSOR_CHAN_HUMIDITY, &humidity);

            print_reading("temp    ", &temp, "deg C");
            print_reading("pressure", &press, "kPa");
            print_reading("humidity", &humidity, "%RH");
            printk("\n");
        } else {
            printk("sample fetch failed (bus error - wiring? address?)\n");
        }
        k_sleep(K_SECONDS(1));
    }
    return 0;
}

It is an ordinary sensor program (fetch, get three channels, print, sleep) with three devicetree moments, and they are the whole point:

  1. #define SENSOR_NODE DT_ALIAS(env_sensor): the application asks for "the node the alias points at", once. Every later use is in terms of that answer. The C file never contains 0x76, never contains i2c0, never contains the string "bme280" outside comments and macro arguments. Run the layering check yourself: grep -n "0x76\|i2c0" src/main.c finds only comments. If a hardware fact ever creeps into code, it will be in addition to the description, and the two will eventually disagree. Keep the description the only source.
  2. The #if DT_NUM_INST_STATUS_OKAY(bosch_bme280) == 0 guard: a compile-time census: "how many enabled instances of this compatible exist?" If the answer is zero (overlay missing, misnamed, compatible misspelled), the build stops with your sentence, not with a silent boot into a sensorless program. Step 7 fires this on purpose. This is a pattern worth stealing for every project: pick the one description fact your application cannot live without, and assert it in words a tired human understands at 1 a.m.
  3. DEVICE_DT_GET(SENSOR_NODE) plus the boot printout: the device object is created by the driver, at an init priority the driver chose, bound to the bus device the description named; the application just holds the handle. And the first two lines it prints (DT_NODE_PATH, DT_REG_ADDR, DT_BUS) are the description quoting itself at runtime: if the overlay changes, the banner changes. On the desk, that banner is your fastest check that the firmware on the board is the firmware you think it is.

One question this code raises, worth answering before the desk test: if DEVICE_DT_GET hands over the device at compile time, why does the program still ask device_is_ready()? Because existing and being initialised are different facts. Zephyr initialises devices in devicetree dependency order, parents before children: the I2C controller's init runs before your sensor's because the tree says the sensor sits on that bus, and the sensor driver's init is where it probes the part and decides the device is usable. DEVICE_DT_GET is an address, resolved by the preprocessor, always available. Readiness is the runtime verdict of that init sequence, and asking for it is how the application respects the machinery instead of assuming it. When the sensor is unplugged for Step 9's test, the driver init is what fails, the device object still exists, and this one call is the difference between a clean sentence on the console and a program marching into reads against a dead bus.

The printing helper deserves one sentence of its own, because it is the same trap Guide 1 documented from the other side: a sensor_value is an integer part and a micro part that shares its sign, so naive formatting prints minus half a degree as 0.-500000. Converting to milli first keeps the sign in one place. It is not devicetree; it is the tax every sensor program pays, paid once, in one function.

Checkpoint: the application asks the devicetree exactly three kinds of questions (which node, what facts about it, is it ready) and hardcodes zero hardware facts. Name them.

Step 5: Build it

From the SDK workspace root, with the toolchain environment loaded:

west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

(If your guide projects live in the Bit to Board workspace rather than inside the SDK tree, pass the application's path: the projects for this site's guides sit at ~/workspace/zigbee-course/guides/devicetree-sensor, and west accepts the absolute path from the workspace root unchanged.)

A healthy build ends with the memory report:

Memory region         Used Size  Region Size  %age Used
           FLASH:       28860 B         1 MB      2.75%
             RAM:        6336 B       256 KB      2.42%

FLASH 28,860 B, RAM 6,336 B. Those numbers are this guide's verified build, from these exact files, on the pinned SDK. Yours should match to the byte; if they do not, something in your tree differs from this page, and that difference (not the sensor, not the wiring) is the first thing to find. Small, though, is the headline: a sensor subsystem, an I2C driver, a sensor driver, and an application, for under 29 KB, because the description selected exactly the code the hardware needed and not a line more.

Checkpoint: build green, memory report noted. Do not flash yet. The next step is the one this guide exists for, and it happens entirely inside the build folder.

Step 6: Prove the description drove the build

A build that succeeds proves the description parsed. It does not prove the description was used, that the node you wrote is the node in the tree, or that the driver present is present because of it. Three proofs, all run in the build folder (build/devicetree-sensor/zephyr/ if you built in-tree, or your -d folder), close that gap. Run all three; they take a minute and they generalise to every devicetree problem you will ever debug.

Proof 1: the merged devicetree names your file. Open zephyr.dts, the fully merged description the build actually consumed, and find your node:

/* node '/soc/i2c@40003000/bme280@76' defined in ../workspace/zigbee-course/guides/devicetree-sensor/app.overlay:23 */
bme280: bme280@76 {
    compatible = "bosch,bme280"; /* in ../workspace/zigbee-course/guides/devicetree-sensor/app.overlay:24 */
    reg = < 0x76 >;              /* in ../workspace/zigbee-course/guides/devicetree-sensor/app.overlay:25 */
};

Every line of the merged tree carries its birth certificate as a comment: which file, which line. Your node sits inside the i2c@40003000 controller node (that address is the SoC description's contribution), holding exactly your two properties, sourced from exactly your overlay. When a description "isn't taking effect", this file is where the argument ends: either the fact is here, sourced from your file, or it is not in the build at all. (The same file shows your alias, env-sensor = &bme280, up in the root's aliases, sourced from app.overlay too.)

Proof 2: the .config shows the selection chain. Grep the generated configuration:

grep -E "CONFIG_BME280|CONFIG_SENSOR=|CONFIG_I2C=" build/devicetree-sensor/zephyr/.config
CONFIG_SENSOR=y
CONFIG_I2C=y
CONFIG_BME280=y

Only the first of those three lines is in your prj.conf (the skeleton experiment, paying off). CONFIG_I2C=y arrived because the board enabled an I2C controller and the driver's select I2C if ... fired. CONFIG_BME280=y arrived because DT_HAS_BOSCH_BME280_ENABLED was true, and it was true because your node exists and is enabled. Delete the node and all three lines change; that is what "the description selects the code" means, mechanically, no adjectives.

Proof 3: the generated header resolved your alias. Everything the C compiler knew about your sensor came from zephyr/include/generated/zephyr/devicetree_generated.h. It is not meant to be read cover to cover (it is tens of thousands of lines of macros), but one grep shows the shape of the trick:

grep "DT_N_ALIAS_env_sensor " build/devicetree-sensor/zephyr/include/generated/zephyr/devicetree_generated.h
#define DT_N_ALIAS_env_sensor    DT_N_S_soc_S_i2c_40003000_S_bme280_76

The alias macro expands to the node's macro name, and that name is just the node's path with the punctuation flattened: soc, i2c@40003000, bme280@76. Every DT_REG_ADDR(node), DT_BUS(node), DT_NODE_PATH(node) you used is a lookup into macros spelled from this path. There is no table, no search, no runtime. The description became names, and the names became your program. Follow one lookup all the way down, because it is the last piece of magic and it evaporates on contact. Your DT_REG_ADDR(SENSOR_NODE) becomes, after the alias, a reference to this line, also in the generated header:

#define DT_N_S_soc_S_i2c_40003000_S_bme280_76_REG_IDX_0_VAL_ADDRESS 118 /* 0x76 */

That is the whole mechanism, end to end. A number you typed into an overlay, once, became a #define whose name is the node's path and whose value is your address, comment included. The driver's instance data is built from the same family of macros (its bus pointer is the parent node's device, resolved the same way), which is why the driver needed no configuration from you either. Whenever a devicetree macro misbehaves in some future project, this header is the ground truth: expand the chain by hand, read the final define, and compare it against the description you meant to write. Nine times out of ten the build described something subtly different from your intention, and the header is where the two of you can finally agree on what was said.

Checkpoint: all three proofs pass, and you can state, for each, what its failure would look like (node absent or sourced from the wrong file; CONFIG_BME280 missing or present without your node, meaning something else selected it; alias macro absent). A proof whose failure you cannot picture is a ritual, not a proof.

Step 7: Break the description on purpose, and read the failure

Knowledge you have not watched fail is borrowed. So: rename the overlay out of the way and rebuild, exactly as if it had never been written.

mv app.overlay app.overlay.hidden
west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

The build fails, and the first error is the sentence from Step 4:

src/main.c:32:2: error: #error "No enabled bosch,bme280 node in the devicetree. Is app.overlay in the application folder, and is its node status okay?"

Below it comes the fallout, a volley of preprocessor complaints (__device_dts_ord_DT_N_ALIAS_env_sensor_ORD undeclared, DT_N_ALIAS_env_sensor_PATH undeclared, and friends). Read them with the model from Step 6 and they stop being noise: the alias macro does not exist, because the node it pointed at does not exist, because the description lost a layer. Every one of those errors is the same fact in the preprocessor's accent. The discipline: fix the first error, ignore the rest, rebuild. And notice what the guard bought you. Without it, the first error would still have been a wall of macro noise, just without your sentence at the top translating it. Two lines of #if turned a riddle into a diagnosis. That is the difference between devicetree feeling capricious and devicetree feeling mechanical: not the absence of failures, but failures that speak your language.

There is a second, quieter lesson in this experiment, and it is the more important one: the build only failed because the application asked. Rename the overlay and remove the guard, and the build would fail later and uglier (the alias macros), but remove the application's devicetree use entirely and the build would succeed with no sensor in it at all: a green build, a dead product. Descriptions do not defend themselves. The guard is you, defending them.

Restore and rebuild:

mv app.overlay.hidden app.overlay
west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

Green again, memory report identical. Checkpoint: you have seen this project's failure mode, in your own words, and you know which single file being absent causes it.

Step 8: Wire the sensor to the header

Power off (unplug the DK). The wiring is Step 1's pinctrl block, made physical. The DK's Arduino header carries the bus on the pins the board description named:

BME280 breakout DK pin Why
VIN / VCC 3.3V (Arduino power header) The breakout's regulator (if fitted) and the chip both accept 3.3 V
GND GND (any) Common ground, or nothing talks
SDA Arduino SDA (P0.26) The pin i2c0_default names for data
SCL Arduino SCL (P0.27) The pin i2c0_default names for clock

Two module gotchas, both cheap to check now and miserable later. First, the address strap: most BME280 breakouts ship with SDO pulled to ground, which is why the overlay says 0x76. A module with SDO tied to VCC (or a pin header that leaves it to you) answers at 0x77. If your module has an SDO pin, it is either wired to ground already or it is your choice; the desk test in Step 9 grades the result either way, and the fix, if needed, is one hex digit in one file. Second, pin naming: some breakouts label the pins SDI/SCK (SPI vocabulary) for what are still the I2C data and clock lines on a dual-mode module. Wire SDI to SDA and SCK to SCL, and make sure the module is in I2C mode (on most breakouts, the CSB pin pulled high or left in its default state; check your module's silkscreen notes).

Checkpoint: four wires, each traceable to a fact in the board description or the binding. No wire is "where the forum said".

Step 9: Flash it and run the desk test

Flash and open the console:

west flash
west attach  # or open the DK's serial port at 115200 in your terminal

Expected console output (desk expectation; the banner and the shape are the test, the digits are your room):

Sensor node: /soc/i2c@40003000/bme280@76
I2C address: 0x76, on bus node /soc/i2c@40003000
Reading bme280@76 once a second. Breathe on it.

temp     22.840 deg C
pressure 98.913 kPa
humidity 41.207 %RH

Four tests grade the whole chain, description to air:

  • Test 1 (the banner): the node path and address on the first two lines match the overlay exactly. They were printed from the generated macros, so a mismatch means the board is running different firmware than this build (flash again), never that the overlay is "sort of" applied. There is no sort of.
  • Test 2 (plausible air): temperature within a degree or two of the room, pressure in the high 90s to low 100s kPa (weather and altitude move it; a reading of 0 or of 1013.25 exactly means you are reading a placeholder, not air), humidity somewhere in the 20 to 60 %RH band indoors.
  • Test 3 (the breath test): cup your hands and breathe slowly over the sensor for ten seconds. Humidity climbs steeply (toward 80 %RH or beyond) and temperature rises a degree or two; both settle back over a minute. This is the test that proves you wired a living sensor and not a fault that happens to print numbers, the same trick Guide 1 used with a thumb on the package.
  • Test 4 (the wrong-address experiment): edit reg in app.overlay to <0x77> (and the unit address to match: bme280@77), rebuild, reflash. The application now boots to Sensor bme280@77 is not ready... and stops. The description lied about a fact, and no compiler on earth can catch a lie about the physical world; only the desk can. Restore 0x76, rebuild, reflash, and sit with what just happened: the address lived in exactly one line of exactly one file, so the experiment cost one edit and the repair was the same edit backwards. That is the entire argument for the description layer, demonstrated instead of asserted.

Checkpoint: all four tests pass. You have a running sensor, a description you can prove the build consumed, and a failure you have already met once on purpose, so it will never surprise you at a customer's site.

If it breaks

Symptom Likely cause Fix
Build stops with your own #error sentence The overlay is missing, misnamed (it must be exactly app.overlay in the application folder), or its compatible is misspelled, so the census found zero enabled nodes Restore/fix the overlay; the sentence names the suspects in order
A wall of macro errors mentioning DT_N_ALIAS_env_sensor Same root cause as above, seen without the guard's translation, or the alias block is missing from the overlay Fix the first error only; verify the alias exists: env-sensor = &bme280;
devicetree error at configure time, about the node or its properties The node violates its binding (a property the binding does not declare, or reg missing) Compare the node against bosch,bme280-i2c.yaml and its includes, property by property
Boot banner prints, then "is not ready" Hardware fact wrong: wiring, power, or the strapped address (module at 0x77, description says 0x76) Check the four wires against Step 8; try the other address in reg and the unit address together
Readings print but humidity never moves, even under a breath The module is a BMP280 (same package, no humidity element), or humidity is being read from a stale struct after a failed channel get Breathe test against a known BME280; treat the module swap as the fix, not the code
Banner shows the wrong path or address The board is running older firmware (the flash did not take, or you flashed a different build folder) west flash from this build; the banner is generated, so it cannot lie about the build it came from
Temperature plausible, pressure absurd (0, or a suspiciously round constant) You are printing a value from a failed fetch, or the module is a clone with a different compensation (rare) Check the fetch return code path; cross-check against a weather report for your town, adjusted for altitude

You are done when

  • The project builds green from these four files, and the memory report matches the guide's (FLASH 28,860 B, RAM 6,336 B) or you know exactly why yours differs
  • You can point at your node in the merged zephyr.dts, read its source comments back to your overlay, and say which layer contributed the parent bus node
  • Your .config shows CONFIG_BME280=y and CONFIG_I2C=y, and you can explain why neither is in prj.conf
  • You induced the missing-overlay failure, read your own #error sentence as the first error, and restored the build to green
  • The boot banner quotes your overlay's path and address back at you, and the breath test moves humidity and temperature
  • You ran the wrong-address experiment and repaired it with a single edit in a single file

Where the description goes from here

You now hold the three moves every devicetree task is made of: read the layer below you, describe your facts once, and prove from the build's own outputs that the description landed. Everything else in the devicetree world is these moves at larger scale. Moving this sensor to SPI, or to the second I2C bus, is an overlay edit and a different set of wires; the application will not notice. Giving a sensor its own driver, when no in-tree driver exists, is Guide 1's subject: Write a Zephyr device driver from scratch writes the binding and driver that a node like this one marries. And when the board itself is yours, Create Zephyr board files from scratch writes the board layer this guide only read, the .dts and pinctrl files that future overlays will amend.

In the Zigbee course, this description layer is the ground the networking stands on: the coordinator, the endpoints, and the sensor all arrive through it, and the engineers who debug Zigbee well are the ones who can prove what the build actually described before they blame the radio. Start with Zigbee, Engineered if that is where you are headed. And when you are ready to watch descriptions and radios meet in the wild, the next guide in this series puts a whole network on the desk: Your first Zigbee network on a desk.

← All guides

GuideZigbee · First network~3 hours, following along

Your first Zigbee network on a desk

Your first Zigbee network on a desk: coordinator, one router, one bulb, sniffed live

Most people's first Zigbee network is a hub they bought, an app they tapped, and a bulb that obeyed for reasons they could not name if their life depended on it. That is fine for lighting a hallway. It teaches nothing. This guide takes the other road: you form the network yourself, from a command line, on radios you flashed, and at the end you sit a third radio next to the first two purely to listen, and read the whole conversation, joins, keys, commands and all, in Wireshark, decrypted with a key you chose.

The cast is deliberately mixed. The coordinator is an nRF52840 dongle running Nordic's Zigbee shell sample, a firmware whose entire user interface is a set of commands (bdb, zdo, zcl) typed over USB. The first router is an nRF52840 DK running the light bulb sample, pretending to be a bulb with its LEDs. The second is not pretend at all: a store-bought IKEA TRÅDFRI bulb in an ordinary lamp, which joins the network you formed and obeys the commands you type. Three radios from the guide, one from a shop shelf, zero hubs, zero apps, zero cloud accounts. If a step fails, there is nowhere for the failure to hide, which is rather the point.

By the end you will have:

  • A Zigbee network you formed by hand, on a channel you picked, with a network key you set yourself (and can therefore read back off the air).
  • A router joined, discovered, and interrogated with over-the-air management commands: its address from the neighbour table, its endpoints, its cluster list, read from the device, not from a datasheet.
  • Both bulbs switched and dimmed by raw ZCL commands from the coordinator's shell, cluster 0x0006, no library, no framework, the protocol itself.
  • A sniffer capture of the network's whole biography: scan, join, key transport, announce, and your commands, decoded frame by frame, with notes on what each frame proves.
  • And the honest version of "Zigbee is encrypted": the over-the-air key delivery you are about to watch is exactly why the classic Trust Center link key is famous, explained from your own capture instead of a slide.

What you need

  • An nRF52840 DK (the router that pretends to be a bulb) and two nRF52840 dongles. One dongle is the coordinator; the second is the sniffer. With only one dongle you can still do everything except sniff while the network lives; the guide marks the sniffer as its own act for that reason, and the coordinator half is worth the afternoon on its own.
  • An IKEA TRÅDFRI bulb (any of the range; a plain white E26/E27 is perfect) and a lamp or socket it fits, within a few metres of the desk for its first join. A bulb that has never been paired is ideal; a previously paired one gets factory-reset in Step 6 using IKEA's own ritual.
  • The pinned workspace from this site's Zigbee material: nRF Connect SDK v3.4.1, with the ncs-zigbee add-on present (the Zigbee samples live in the add-on, not the base SDK, and the build commands below pass it to west explicitly so there is no ambiguity about which samples you are building).
  • Wireshark on the desk computer, for the final act, with permission to install one extension (the nRF Sniffer for 802.15.4 extcap, named in Step 7).
  • About three hours, following along. Nothing here is individually slow; the time goes on discovery and on reading the capture, which is where the learning is.

The cast, and the plan

Role Hardware Firmware Its job
Coordinator nRF52840 dongle #1 Zigbee shell sample, USB variant Forms the network, admits devices, is the shell you type at
Router "bulb" nRF52840 DK light_bulb sample Joins as a router; its LED 4 plays the bulb, LED 3 reports "joined"
Real bulb IKEA TRÅDFRI in a lamp IKEA's own Joins the same network; proof the network is a network, not a demo
Sniffer nRF52840 dongle #2 802154_sniffer sample Says nothing, hears everything on the channel, feeds Wireshark
Sniffer (second dongle)IKEA bulbRouter bulb (DK)Coordinator (dongle, shell)Sniffer (second dongle)IKEA bulbRouter bulb (DK)Coordinator (dongle, shell)bdb role zc, set key, bdb start: network existsJoin: association, then Device Announcezdo discovery: endpoints and clusters, read livezcl cmd: On/Off Toggle, Level ControlJoin after its reset ritual, same ceremonyzcl cmd: Toggle a real bulb in a real lampThe same frames again, with the sniffer listening and the key in Wireshark

One habit to adopt before the first command: the shell is the datasheet's revenge. Every Zigbee concept in this guide (roles, endpoints, clusters, binding tables, neighbour tables) shows up as a command with output you can read, not as a diagram in a book. When something confuses you, there is almost always a zdo command that shows you the live state of the network instead of your assumptions about it. Reach for it.

Step 1: Build and flash the coordinator

The coordinator is the add-on's shell sample: a full Zigbee stack whose application is the command interpreter. Build its USB variant for the dongle. The file suffix mechanism is doing the selection: with FILE_SUFFIX=usb, sysbuild hands the application prj_usb.conf and app_usb.overlay instead of the UART defaults, and those two files are the difference between "shell on pins you cannot reach" and "shell on the USB connector the dongle actually has".

west build -p always --sysbuild -b nrf52840dongle/nrf52840 \
  ncs-zigbee/samples/shell \
  -d build-coordinator \
  -- -DZEPHYR_EXTRA_MODULES=$PWD/ncs-zigbee -DFILE_SUFFIX=usb

(Paths assume you run from the workspace root that contains both the SDK and the ncs-zigbee add-on folder, the layout this site's Zigbee course uses. Adjust the two paths, not the flags: it is the --sysbuild, the board, and the FILE_SUFFIX that make this build the coordinator and not something else.)

This guide's verified build of exactly that command reports FLASH @@FLASH_COORD@@ B for the application image. Before flashing, prove the variant took, from the build's own output rather than from hope:

grep -E "CONFIG_ZIGBEE_SHELL=|CONFIG_USB_DEVICE_STACK_NEXT=" build-coordinator/shell/zephyr/.config

Both must read =y. If CONFIG_ZIGBEE_SHELL is missing you built the wrong sample; if the USB line is missing, the suffix did not reach sysbuild and you have a UART shell on a board with no accessible UART, which presents, unhelpfully, as total silence.

The two files the suffix selected, and what to notice in them. First the configuration:

#
# Copyright (c) 2025 Nordic Semiconductor ASA
#
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
#

CONFIG_NCS_SAMPLES_DEFAULTS=y

# LOG configuration
CONFIG_LOG_CMDS=y
CONFIG_LOG_MODE_DEFERRED=y
CONFIG_LOG_BUFFER_SIZE=4096
CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=1024
CONFIG_LOG_PROCESS_THREAD_STARTUP_DELAY_MS=1000

# Configure serial
CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_SERIAL=y
CONFIG_GPIO=y

# Make sure printk is not printing to the UART console
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y

CONFIG_HEAP_MEM_POOL_SIZE=2048
CONFIG_MAIN_THREAD_PRIORITY=7

CONFIG_ZIGBEE_ADD_ON=y
CONFIG_ZIGBEE_APP_UTILS=y
CONFIG_ZIGBEE_ROLE_ROUTER=y

# Enable DK LED and Buttons library
CONFIG_DK_LIBRARY=y

# This example requires more workqueue stack
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048

# Networking
CONFIG_NET_IPV6=n
CONFIG_NET_IP_ADDR_CHECK=n
CONFIG_NET_UDP=n

# Zigbee shell
CONFIG_ZIGBEE_SHELL=y
CONFIG_ZIGBEE_SHELL_DEBUG_CMD=y
CONFIG_ZIGBEE_SHELL_ENDPOINT=64

# Increase RX serial ring buffer
CONFIG_SHELL_BACKEND_SERIAL_RX_RING_BUFFER_SIZE=128

# Use new USB stack with CDC ACM initialized at boot
CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_CDC_ACM_SERIAL_VID=0x1915
CONFIG_CDC_ACM_SERIAL_PID=0x0100
CONFIG_CDC_ACM_SERIAL_MANUFACTURER_STRING="Nordic Semiconductor ASA"
CONFIG_CDC_ACM_SERIAL_PRODUCT_STRING="Zigbee Shell"


CONFIG_USBD_LOG_LEVEL_WRN=y
CONFIG_UDC_DRIVER_LOG_LEVEL_WRN=y
# Disable USB CDC ACM logging to avoid conflicts when console/shell uses the same interface
CONFIG_USBD_CDC_ACM_LOG_LEVEL_OFF=y

# Enable this to duplicate Zephyr logs to default Logger serial backend,
# usually connected to on-board JLink device.
# CONFIG_LOG_BACKEND_UART=y

It is a complete configuration, not a delta: the suffix swaps the whole file, which is why the Zigbee lines (add-on on, shell on, shell endpoint 64, role router by default) appear here as well as in the base file. Three lines earn their keep. CONFIG_ZIGBEE_SHELL_ENDPOINT=64 parks the shell's own endpoint high, out of the way of real device endpoints. CONFIG_CDC_ACM_SERIAL_PRODUCT_STRING="Zigbee Shell" is the name the dongle will introduce itself with to your computer, so in Step 2 you can tell its serial port from the sniffer's ("nRF 802154 Sniffer") at a glance. And CONFIG_USB_DEVICE_STACK_NEXT=y with the CDC ACM block is the "new USB stack", a virtual serial port built from a devicetree node, which is the overlay:

/*
 * Copyright (c) 2025 Nordic Semiconductor ASA
 *
 * SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
 */

&zephyr_udc0 {
    cdc_acm_uart: cdc_acm_uart {
        compatible = "zephyr,cdc-acm-uart";
    };
};

/ {
    chosen {
        zephyr,shell-uart = &cdc_acm_uart;
    };
};

Seventeen lines with a familiar moral (Guide 3 in this series is entirely about them): a cdc_acm_uart node under the USB device controller, and a chosen pointer, zephyr,shell-uart, telling the shell subsystem "your transport is that node". Hardware described once, consumed by name. Flash it:

west flash -d build-coordinator

The dongle has no debug probe; its runner packages the image for the dongle's own bootloader and pushes it over USB (if the push waits, the dongle wants its reset button pressed to enter the bootloader, a small sideways button on the board; the DK-style west flash ceremony it is not, and one press is the whole ritual).

Checkpoint: .config shows both proofs at =y, the flash completed, and you can name which mechanism (file suffix, sysbuild, overlay chosen) put the shell on the USB port.

Step 2: Form the network, choosing the facts yourself

Open a terminal on the dongle's serial port (it enumerates as a USB CDC device named Zigbee Shell; on Linux typically /dev/ttyACM0, 115200 baud, though USB CDC ignores the baud in every way that matters). Press Enter; the shell answers with its prompt, zigbee:~$. A coordinator is not a thing the firmware is; it is a role the stack takes when told, and the telling is three decisions you are about to make consciously instead of inheriting from a hub:

zigbee:~$ bdb role zc
Coordinator
Done
zigbee:~$ bdb nwkkey 00112233445566778899aabbccddeeff
Done
zigbee:~$ bdb channel 15
Done
zigbee:~$ bdb start
Started coordinator
Done
  • bdb role zc sets the role (the shell sample supports coordinator and router; end device is deliberately not its business). Ask without setting, bdb role, and it reports what it currently is: on a fresh flash, zr, a router looking for a network that does not exist yet.
  • bdb nwkkey ... sets the network key, before formation, which is the only order that matters. The value here is the example key from Nordic's own shell documentation, chosen openly and on purpose: in Step 7 you will hand this exact string to Wireshark, and the network's traffic will open like a book. A hub never shows you its key. You are not a hub; set yours, write it down, and notice that a Zigbee network's secrecy lives and dies with this one 128-bit number.
  • bdb channel 15 fixes the channel instead of letting formation scan and choose. For a desk network any of 11 to 26 works; fixing it buys determinism, because the sniffer in Step 7 must be told one channel to listen on, and "wherever the coordinator felt like" is not a channel. (Channel 15 specifically is one of the four Zigbee channels, 15, 20, 25 and 26, that sit in the gaps between the three crowded Wi-Fi channels 1, 6 and 11. On a desk in an apartment building that choice is the difference between a network that joins first try and one that sulks. When a Zigbee network misbehaves in exactly one room of a house, this paragraph is usually the explanation.)
  • bdb start runs base-device commissioning: the stack forms the network with the role, key, and channel above, generates a PAN ID, and opens for business. Started coordinator is the line that means the network exists.

Two state commands worth meeting now, while the network has exactly one member: bdb panid and bdb extpanid read back the identifiers formation chose (pass a value to set them instead, if you ever need a second desk network that must not merge with the first). And a realism note: the network is now open to joining in the way fresh commissioning leaves it, which is why Step 6's bulb can simply arrive. A production coordinator would manage its join windows deliberately; a teaching coordinator leaves the door open so the ceremony can happen on cue.

Checkpoint: bdb start answered Started coordinator, and you can state the network's three founding facts (role, channel, key) because you chose all three. If the terminal stayed silent from the very beginning, the Step 1 variant proof is where to look; a UART-shelled dongle has nothing to say to a USB cable.

Step 3: Build the router bulb, and watch it join

The DK's act is the add-on's light bulb sample: a router with a dimmable light endpoint, whose physical output is an LED. Its configuration is a single file worth reading whole, because it is the router counterpart of the coordinator's:

#
# Copyright (c) 2024 Nordic Semiconductor ASA
#
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
#

CONFIG_NCS_SAMPLES_DEFAULTS=y

CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_SERIAL=y
CONFIG_GPIO=y
CONFIG_PWM=y

# Make sure printk is not printing to the UART console
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y

CONFIG_HEAP_MEM_POOL_SIZE=2048
CONFIG_MAIN_THREAD_PRIORITY=7

CONFIG_ZIGBEE_ADD_ON=y
CONFIG_ZIGBEE_APP_UTILS=y
CONFIG_ZIGBEE_ROLE_ROUTER=y

# Enable DK LED and Buttons library
CONFIG_DK_LIBRARY=y

# This example requires more workqueue stack
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=2048

# Scene extension
CONFIG_ZIGBEE_SCENES=y

The load-bearing line is CONFIG_ZIGBEE_ROLE_ROUTER=y: a router is mains-powered, always listening, and forwards other devices' traffic; it is the spine a real network grows on, and the reason a desk network wants one before it wants anything battery-powered. The rest is the product around it: the DK library for the LEDs, the scenes and level clusters a bulb is expected to speak, and OTA client plumbing this guide does not use but a real bulb ships.

west build -p always --sysbuild -b nrf52840dk/nrf52840 \
  ncs-zigbee/samples/light_bulb \
  -d build-bulb \
  -- -DZEPHYR_EXTRA_MODULES=$PWD/ncs-zigbee
west flash -d build-bulb

This guide's verified build reports FLASH @@FLASH_BULB@@ B. Flash the DK (this one does have a debug probe, so the familiar west flash is the whole ceremony), leave it powered, and give the join a moment. The sample's own user interface grades it for you: LED 1 blinks to say the application thread is alive, and LED 3 turns on when the bulb has joined a network. LED 3 lit, near a coordinator whose network you formed in Step 2, means the ceremony already happened: the DK scanned, found your channel 15 network, associated, received the network key, and announced itself.

Now prove it from the network's side, at the coordinator's shell, because "the LED says so" is the device's claim and a network engineer checks claims. The tool is the neighbour table, read over the air from the coordinator itself:

zigbee:~$ zdo mgmt_lqi 0x0000

mgmt_lqi asks a device (here 0x0000, the coordinator, itself) to report its neighbours. In the table that comes back, find the new entry: a router whose short address is not 0x0000. Write that address down; it is the DK's name on this network, assigned at join, and every command from here on is addressed to it. If the table shows only the coordinator, the join did not happen, and the suspects, in order, are: distance (bring the DK close for a first join; Zigbee is polite, not heroic), the coordinator's state (bdb role must say zc and the network must be started), and a DK still married to some earlier network from another experiment (use the sample's own factory reset (hold Button 4 for five seconds, per the sample's user interface; under five starts Identify mode instead), or reflash with a full erase, and let it meet this network as its first).

Checkpoint: LED 3 is lit on the DK, and the coordinator's neighbour table contains the DK's short address, a number you read off the air rather than assumed.

Step 4: Interrogate the bulb: endpoints and clusters, read live

You have an address. You know nothing else about the device at it, and the correct next move is to ask it, which is what the Zigbee Device Object commands exist for. Two questions unlock everything:

zigbee:~$ zdo active_ep 0x1234

(Your address in place of the documentation's example 0x1234, here and everywhere below.) The answer lists the device's endpoints: the distinct logical devices sharing one radio. Expect a short list, typically just endpoint 1 for the bulb sample, plus the stack's own. An endpoint is where a function lives. Commands are addressed to an endpoint, not to a device, which is why a single physical product can be a light, a switch, and a configuration server at once without confusion.

zigbee:~$ zdo simple_desc_req 0x1234 1

The simple descriptor is the endpoint introducing itself, and the shell prints it in the documentation's shape: application profile id 0x0104 (the Zigbee Lighting / Home Automation profile), an application device id (a dimmable light), and two lists that matter more than everything else on this page: in_clusters and out_clusters. In the input list, look for:

  • 0x0000 Basic, 0x0003 Identify, 0x0004 Groups, 0x0005 Scenes: the housekeeping every certified device carries.
  • 0x0006 On/Off: the cluster Step 5 is about to use.
  • 0x0008 Level Control: brightness, the bulb sample's headline feature.

Read the direction correctly: input clusters are the ones the endpoint accepts commands on (server role); output clusters are ones it sends (client role). A switch product shows 0x0006 in its output list and a bulb in its input list, and that one asymmetry is the whole client/server model of ZCL, visible in a single command's output. Nothing here came from the sample's documentation; the device reported it about itself, over the air, to a stranger. That is discovery, and it is why Zigbee devices from different decades interoperate: nobody hardcodes anybody.

Checkpoint: from live output alone, you can name the bulb's On/Off endpoint and recite which side of 0x0006 it sits on. If active_ep returns nothing, the address is wrong (neighbours change across rejoins; re-read Step 3's table) or the device fell off the network.

Step 5: Switch it, with the raw protocol

Everything so far was management. This is the act the network exists for: a ZCL command, built by hand, from the shell's zcl cmd. Its grammar, from the shell documentation, is:

zcl cmd [-d] <dst_addr> <ep> <cluster> [-p <profile>] <cmd_ID> [-l <payload>]

The worked example in Nordic's documentation is an Off command, and it is three facts wearing almost no syntax: address 0x1234, endpoint 10, cluster 0x0006, command 0x00. Apply it to your bulb, whose endpoint Step 4 just read:

zigbee:~$ zcl cmd 0x1234 1 0x0006 0x00

Command 0x00 on the On/Off cluster is Off, 0x01 is On, 0x02 is Toggle:

zigbee:~$ zcl cmd 0x1234 1 0x0006 0x01
zigbee:~$ zcl cmd 0x1234 1 0x0006 0x02

On the DK, LED 4 obeys: it is the sample's dimmable light, so these commands land as light, not as log lines. Sit with the Toggle for a second, because it is the single most instructive command in Zigbee. Toggle carries no state: you did not read the bulb, compute an inverse, and send it. The device tracked its own state and flipped it, which is what makes one button able to serve many lights and what makes scenes and groups possible. Then dim it, on Level Control (0x0008), command 0x00 (Move to Level), whose payload is a level byte and a transition time in tenths of a second, little-endian:

zigbee:~$ zcl cmd 0x1234 1 0x0008 0x00 -l 0x640a00

0x64 is level 100 of 254, about 40 percent; 0x0a00 is ten tenths, one second, low byte first. LED 4 glides instead of snapping. The -l payload, raw bytes you composed from the cluster's command definition, is the moment the protocol stops being an abstraction: every Zigbee product feature you will ever ship is, at the bottom, a cluster, a command id, and a payload laid out exactly this way.

Two honest notes before the shop-bought bulb. First, these commands are unbound and polled by hand: you are the binding, remembering addresses and typing. Real products replace you with binding (a stored instruction, "send my On/Off output to that endpoint's input") and reporting (the device volunteering its state). Both are the Zigbee course's middle chapters; here, typing is the teacher. Second, add -d to any zcl cmd and the shell waits for and shows the default response, the APS-level acknowledgement ZCL sends back; on a healthy link you get a success status, and on a wrong endpoint or cluster you get a readable failure instead of silence. When in doubt, -d is how a command confesses.

Checkpoint: LED 4 turns off, on, toggles, and dims on command, and for each you can say the cluster, command id, and payload that did it, from memory, in protocol terms rather than shell terms.

Step 6: Admit the real bulb

Time to find out whether the network you formed is a Zigbee network or a Nordic-themed diorama. Screw the TRÅDFRI bulb into its lamp, within a few metres of the coordinator, and switch it on. A bulb that has never been paired begins looking for a network on its own, and yours is open. Most desk joins complete within tens of seconds: expect the bulb to settle to a steady on, and the coordinator's neighbour table (zdo mgmt_lqi 0x0000, Step 3) to gain a second router entry with a fresh short address.

One more door worth knowing about before you need it: older devices. Zigbee 3.0 networks accept current devices by design, but genuinely old bulbs (pre-2016, built to the earlier profiles) sometimes refuse a modern coordinator's joining terms. The shell carries the documented lever for exactly this: bdb legacy enable, issued before bdb start, relaxes the commissioning for those legacy devices. A TRÅDFRI bulb does not need it and this guide never turns it on. File it away as the first thing to try the day a yard-sale bulb from 2014 ignores a network that everything else joins happily, because that refusal is a version negotiation, not a defect.

A bulb that has been paired, to a hub or a previous experiment, ignores your network completely, and no command of yours can persuade it otherwise; it must be made single again. IKEA's factory reset is a power ritual, documented by IKEA and easy to fumble at speed: turn the lamp off and on six times, about a second each, ending with on. The bulb blinks once to confirm the reset, forgets its network, and starts hunting for a new one, which it finds, per above. (Six, not five: the near-miss is the classic failed reset, and the bulb keeps serenely ignoring you, married to a hub in a drawer somewhere.)

With its address from the neighbour table, repeat Step 4's discovery on the real bulb, and compare like an engineer:

zigbee:~$ zdo active_ep 0x5678
zigbee:~$ zdo simple_desc_req 0x5678 1

What you should see (desk expectation, from the TRÅDFRI family's published behaviour, but read it off your own bulb): endpoint 1 carrying profile 0x0104, with 0x0006 On/Off and 0x0008 Level Control among its input clusters, alongside IKEA's extras (a manufacturer-specific cluster in the 0xFCxx private range is normal and ignorable). Then the only test that matters:

zigbee:~$ zcl cmd 0x5678 1 0x0006 0x02

A bulb designed in Älmhult, sold in a flat box, running firmware you will never see, toggles in your lamp because a stack you built spoke a protocol both of you implement. No account was created in the making of this moment. If it does not toggle, run the Step 5 discipline in order: re-read the neighbour table (the address may have changed across its resets), re-run discovery on the address you measured, and add -d so a wrong endpoint tells you so in words.

Checkpoint: two routers in the neighbour table, one silicon, one Swedish, and a real lamp answering raw ZCL from a dongle. The network is a network.

Step 7: Sniff the whole biography, and read it

Everything above happened on air you could not see. Fix that. The second dongle runs Nordic's 802.15.4 sniffer sample: firmware that puts the radio into promiscuous receive on one channel and streams every frame it hears to Wireshark over USB, saying nothing itself. Its configuration is small enough to read whole:

# Enabling radio driver
CONFIG_NETWORKING=y
CONFIG_IEEE802154=y
CONFIG_IEEE802154_RAW_MODE=y
CONFIG_NET_PKT_TIMESTAMP=y

# Allow sharing the RTC between IEEE 802.15.4 and Zephyr
CONFIG_NRF_RTC_TIMER_USER_CHAN_COUNT=3

# Increase the number of RX buffers to minimize the chances of frame drops.
# The value must not be higher than 127 - X, where X is the number of notification
# slots reserved by the radio driver (see NTF_PRIMARY_POOL_SIZE macro definition).
CONFIG_NRF_802154_RX_BUFFERS=120

# Shell configuration
CONFIG_SHELL=y
CONFIG_SHELL_PROMPT_UART=""
CONFIG_SHELL_VT100_COLORS=n

# LED indication
CONFIG_DK_LIBRARY=y

# USB configuration
CONFIG_SHELL_BACKEND_SERIAL_CHECK_DTR=y
CONFIG_UART_LINE_CTRL=y
CONFIG_SHELL_BACKEND_SERIAL_INIT_PRIORITY=51

CONFIG_USB_DEVICE_STACK_NEXT=y
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y
CONFIG_CDC_ACM_SERIAL_PID=0x0004
CONFIG_CDC_ACM_SERIAL_MANUFACTURER_STRING="Nordic Semiconductor ASA"
CONFIG_CDC_ACM_SERIAL_PRODUCT_STRING="nRF 802154 Sniffer"
CONFIG_CDC_ACM_SERIAL_VID=0x1915
CONFIG_CDC_ACM_SERIAL_PID=0x154b

# Disable boot banner
CONFIG_BOOT_BANNER=n
CONFIG_NCS_BOOT_BANNER=n

Three lines do the work: CONFIG_IEEE802154_RAW_MODE=y (frames delivered whole, not filtered by address, the sniffer's defining trait), the receive-buffer count turned up to 120 (a listening device that drops frames is a liar; buffers are its conscience), and the USB identity at the bottom, product string "nRF 802154 Sniffer", the name that will distinguish this dongle's port from the coordinator's in every tool you open. Build and flash it:

west build -p always --sysbuild -b nrf52840dongle/nrf52840 \
  nrf/samples/peripheral/802154_sniffer -d build-sniffer
west flash -d build-sniffer

This guide's verified build reports FLASH @@FLASH_SNIFF@@ B. In Wireshark, install the nRF Sniffer for 802.15.4 extcap (the integration the sample's own documentation names; it appears as a capture interface once installed). Select that interface, set its channel to 15, the channel Step 2 chose, and start the capture.

Then the step that makes the capture worth reading: give Wireshark the key. Under the Zigbee protocol preferences (Edit, Preferences, Protocols, Zigbee), add the network key from Step 2, 00112233445566778899aabbccddeeff. Without it, you will see the 802.15.4 and NWK envelopes and encrypted APS payloads, shapes without sentences. With it, the capture from a fresh run decodes end to end.

Now run the whole play again, with the ear open, because a network's biography is told once, at its birth:

  1. Start the capture. Power the DK bulb off for now.
  2. Restart the coordinator's network if it is not already up, then re-admit the DK bulb (power it on) and watch the join.
  3. Reset the TRÅDFRI again (six toggles) and watch a retail join.
  4. Toggle both bulbs from the shell.
  5. Stop the capture, save it, and read it with the guide below.

The frames to hunt, in the order the network lived them, and what each one proves:

Frame (Wireshark display) What actually happened What it proves
Beacon Request, then Beacon responses The joining device scanned; networks introduced themselves (PAN id, permit-joining flag) Discovery is a shout and an answer, not magic; your coordinator's beacon carries your PAN id
Association Request / Response (MAC layer) The bulb asked the coordinator for membership and was given a short address The 16-bit name every later command uses is born here, on air
Device Announce (ZDO, NWK) The new device tells the whole network its short and IEEE addresses This is the broadcast your mgmt_lqi table later reflects; the network learns routes from it
Transport Key (APS, from the coordinator) The network key, delivered to the joiner The most instructive frame in Zigbee. It is itself encrypted, with the well-known Trust Center link key, which is exactly why capturing a join plus knowing that famous key lets an observer recover a network's key, and why production networks use install codes. Your capture, your key, your lesson: never sniff a join you do not want read
APS Command: On/Off Toggle + Default Response Your zcl cmd, flying, and the bulb's ZCL-level answer With the key entered, the payload decodes to cluster 0x0006, command 0x02, the very numbers you typed; the response shows the status byte, the same one -d surfaces in the shell
Link Status (periodic, NWK) Routers gossiping about their neighbours and costs The background hum of a healthy network; a capture with no commands still breathes

Read the capture once at the frame list level (the story), once into the Zigbee NWK and APS layers of a join (the addressing), and once into the Toggle's ZCL payload (the application). Three passes, three altitudes, one file. That file is also, not incidentally, the single most useful debugging artefact in all of Zigbee development: every future "the bulb does not respond" in your career starts with exactly this capture and the question "which of these frames is missing?"

Save the capture with a name that will mean something in six months (network, date, and the event: desk-net_join-and-toggle_2026-10-23.pcapng beats capture_final_v2.pcapng at 1 a.m. in a customer's kitchen). A small library of known-good captures, one per ceremony, is the embedded equivalent of a test suite: when a device misbehaves you diff its behaviour against the ceremony it should have performed, frame by frame, and the missing or malformed frame is your bug report, already written. The Transport Key row of the table above deserves the last word on security hygiene: everything the observer needed was a radio in the room during the join plus a key published in every Zigbee book ever printed. Production commissioning answers this with install codes, a per-device secret printed on the product, from which the link key for that one join is derived. When the Zigbee course reaches its final chapters, that is the ceremony it performs. On your desk, with your own key, the open version is not a vulnerability; it is the lesson, learned once, properly.

Checkpoint: a saved capture in which you can point at the association, the Transport Key, and a decoded Toggle, and narrate the join from frames alone. If the APS payloads stay encrypted, the key in Wireshark's preferences and the capture's completeness are the two suspects: the Transport Key frame must be in the capture for the story to read as a story, though your manually entered key decrypts the commands regardless.

What the hub was doing for you

It is worth pausing, before the troubleshooting table, to cash in the afternoon's real lesson. Nothing you did today was new technology. It was the same technology a consumer hub runs, with the curtain removed. The mapping is one to one:

The hub app's magic button What it actually did, in today's vocabulary
"Add device" Opened the join window, then waited for a Device Announce and read the neighbour table you read with mgmt_lqi
The device page ("IKEA bulb, firmware x, signal good") active_ep and simple_desc_req answers, cached and prettified, plus link quality from frames like the ones in your capture
The big friendly toggle One APS frame: cluster 0x0006, command 0x02, to an endpoint it discovered exactly the way you did
"Your bulb is up to date" The OTA cluster quietly doing, on schedule, what this network leaves manual
The hub "just working" after a power cut The coordinator's network parameters surviving in non-volatile memory, routers rejoining, the ceremony from your capture replaying itself at 3 a.m. without witnesses

Every layer of that table is a place a product can now break in front of you instead of inside a plastic puck. When a hub refuses to add a bulb, you now know the four ceremonies that could have failed (scan, association, key transport, announce) and own the instrument that distinguishes them. That is the difference between using a protocol and engineering with it, and it is the entire reason this guide formed a network by hand instead of unboxing one.

If it breaks

Symptom Likely cause Fix
No zigbee:~$ prompt on the dongle's port You flashed the UART variant (no FILE_SUFFIX=usb), or you opened the sniffer's port instead Re-check the Step 1 .config proofs; pick the port whose device name is "Zigbee Shell"
bdb start does not say Started coordinator The role was never set, or the stack kept router state from a previous life bdb role to read, bdb role zc to set, then bdb start, in that order
DK bulb LED 3 never lights No join: distance, coordinator not started, or the DK still bound to an older network Move it close; verify the coordinator formed; factory-reset the DK bulb (Button 4, five seconds) or reflash with a full erase, and let it join fresh
Neighbour table shows the bulb, but zcl cmd is met with silence Wrong endpoint or a stale address after a rejoin Re-run active_ep and simple_desc_req on the address you measured now; add -d so failures answer in words
IKEA bulb ignores the network entirely It is still paired to its old network; the reset ritual was miscounted Six full off/on cycles, ending on, about a second apart; watch for the single confirming blink
IKEA bulb joins but discovery shows no 0x0006 on endpoint 1 You are interrogating the wrong endpoint (some TRÅDFRI variants expose more than one) Walk every endpoint active_ep returned with simple_desc_req; the light endpoint is the one carrying On/Off as an input cluster
Wireshark shows frames but Zigbee payloads stay encrypted Key not entered, mistyped, or the sniffer sits on a different channel than the network Channel 15 in the extcap (Step 2 fixed it for this reason); re-enter the key byte for byte
Capture has commands but no join frames The capture started after the joins happened Rerun Step 7's play: reset and rejoin a bulb while capturing; joins only happen once per device per network
Everything worked yesterday, nothing joins today The coordinator dongle was reflashed or reset and formed a new network (new PAN or key) while the bulbs stayed loyal to the old one Read bdb panid and the key you set; reset the bulbs (Step 6 ritual for the IKEA; Button 4 held five seconds for the DK) so both sides agree on which network exists

You are done when

  • The coordinator was built with the USB variant proven from its .config, and its shell answers on the "Zigbee Shell" port
  • You formed the network with your own role, channel, and network key, and bdb start answered Started coordinator
  • The DK bulb joined (LED 3) and its short address came from the coordinator's neighbour table, not from a guess
  • You read the DK bulb's endpoints and clusters from the device itself and can say which side of 0x0006 it sits on, and why
  • Both bulbs toggled, and the DK dimmed, from raw zcl cmd lines whose cluster, command, and payload you can recite
  • A TRÅDFRI bulb you reset yourself joined the network and obeyed the same commands as the development board
  • Your Wireshark capture contains a join (association, Device Announce, Transport Key) and a decoded On/Off command, and you can narrate both from the frames

Where this network goes from here

On this desk you were the binding table, the scheduler, and the group manager, typing each command by hand. Real Zigbee engineering begins exactly where this guide stops: binding, so the switch finds the bulb without you; reporting, so state arrives unsolicited instead of being polled; and devices that sleep for years on a coin cell and must be commissioned, polled, and updated accordingly. The description layer underneath all of it, the devicetree that decided which radio, which pins, which endpoint numbers, is the previous guide in this series: Zephyr devicetree, explained by wiring one sensor end to end, and the driver layer beneath that is Write a Zephyr device driver from scratch.

The full journey, from this exact coordinator-and-shell setup through commissioning, endpoints, binding and reporting, an occupancy sensor steering a bulb, signed firmware updates over the air, and packet captures of your own network under your own key, is the course this guide is the front porch of: Zigbee, Engineered. You have already met its coordinator. The rest of it is learning to build the other chairs at the table, and then the house they sit in.

← All guides

The project · Zigbee · Zephyr · nRF52

Zigbee, Engineered

Most Zigbee tutorials end at "hello, network." This one ends at a product: a standards-compliant occupancy sensor you designed, commissioned, bound to a real bulb, signed, and updated over the air, with the packet captures to prove all of it.

11 chapters 28 to 35 hours Intermediate Labs + quizzes every chapter
Get the course. US$79 Preview chapter 1

Chapter 1 is free with a free account. The full course unlocks when you buy it.

An electronics workbench with a monitor, oscilloscope, meters and a development board
The problem

Knowing Zigbee words isn't knowing Zigbee

The usual way

  • Slides about clusters you'll never declare yourself
  • A vendor demo that joins a network someone else formed
  • "It works on my desk", until the first failed join, with no idea where to look
  • Updates, security and rollback left as an exercise for your future self

This course

  • You declare the endpoint and the cluster, the standard way, so any ecosystem accepts it
  • You form the network from a shell and watch every key change hands
  • A field guide for failed joins, and a sniffer capstone to diagnose from the air
  • Signed images, rollback drills, and OTA, treated as part of the product, not extras
What you build

One desk, one product

An HLK-LD2410 mmWave radar watches the room. Your nRF52840 DK turns its raw UART frames into the standard Zigbee Occupancy Sensing cluster. A coordinator on a dongle, driven entirely from its shell, runs the network. An IKEA TRÅDFRI bulb obeys. In Chapter 11 a second dongle sniffs the air and you decode the whole system in Wireshark.

Your computer west build / flash serial terminals · the shell Wireshark (Chapter 11) Dongle 1 · Coordinator (Ch 5) Dongle 2 · Sniffer (Ch 11) USB USB One Zigbee network formed in Chapter 5 · everything joins it PAN ID · channel · network key nRF52840 DK the device you write — a Zigbee router Occupancy Sensing endpoint (Ch 7) reports & binding switch the bulb (Ch 8) HLK-LD2410 mmWave sensor (Ch 4) UART · 5 V · GND IKEA TRÅDFRI bulb joins in Ch 6 · switched in Ch 8 forms the network joins as router joins as a light USB — flash + logs + shell

The whole course on one desk, the actual architecture figure from the course front matter.

Peek inside

Real pages, real diagrams

39 diagrams across the course, block, sequence and state diagrams wherever a flow exists. Three of them:

The network's cast

Coordinator, routers, end devices, and who holds the keys. (Chapter 5)

802.15.4 · ch 11–26

🟡 Coordinator — dongle
0x0000 · trust center
forms the network, holds the keys

🔵 Router — IKEA bulb
relays, parents children

🔵 Router — our DK sensor
(Chapter 6 firmware)

⚪ End device — (Part 2)
sleepy sensor, child of a router

⚪ End device — remote
wakes, asks parent, sleeps

Joining is a key exchange

The full join sequence, frame by frame. (Chapter 5)

Trust center(= the coordinator)Parent (coordinator or routerwith joining permitted)Joining deviceTrust center(= the coordinator)Parent (coordinator or routerwith joining permitted)Joining devicethe device is now ADDRESSABLEbut cannot read network traffic yetfull member: holds the key,routes or sleeps per its roleBeacon Request (scanning channels)Beacon — "network here, joining open"Association RequestAssociation Response — short address assignedTransport Key — the network key,encrypted under the link keyDevice Announce (broadcast) —"this EUI-64 is now at this short address"

From radio wave to Wireshark

The sniffer capture chain you build in the capstone. (Chapter 11)

Your computer

Sniffer dongle

On the air

radio waves

USB serial text:
received: … power: … lqi: …

Sensor DK, bulb, coordinator
talking Zigbee

802.15.4 radio
promiscuous RX, one channel

802154_sniffer firmware
prints each frame as text

extcap plugin
parses the text, rebuilds frames
wraps them in 802.15.4 TAP

Wireshark dissectors
wpan, NWK, APS, ZCL

Course profile
colors and saved filters

You, reading fields

Read chapter 1 free with a free account
Watch it move

Real animations from inside the lessons

Join sequences and frame formats are hard to learn from a static picture, so the lessons animate them, and hand you the controls. These three are lifted straight out of the chapters, untouched: press play, or step through them frame by frame.

Animated diagram

Joining is a key exchange, the five moves

Joining deviceknows nothing yetParentcoordinator or routerTrust center= the coordinatorBeacon Request, "who is out there?"
Joining deviceknows nothing yetParentcoordinator or routerTrust center= the coordinatorBeacon: "network here, joining open"
Joining deviceaddressable nowParentcoordinator or routerTrust center= the coordinatorAssociation RequestAssociation Response. You are 0x3F2A…but it holds NO network key: it can be spoken to, and can decrypt nothing.
Joining deviceabout to be trustedParentrelays if remoteTrust center= the coordinatorTransport Key: network key, wrapped in the link key🔑 the security perimeter, in one frame
Joining deviceFULL MEMBER ✓Parentcoordinator or routerTrust center= the coordinatorDevice Announce (broadcast): EUI-64 ↔ 0x3F2Anaming preceded trusting: mechanically, it could not be the other way round

Animated diagram

One LD2410 frame, byte by byte

LD2410 frame on the wire: 256,000 baud, ~24 bytes per reportF4F3F2F10D0001AA55··F8F7F6F5headerlengthdata: target state, distance, energy…tailSEEK→HEADER→LENGTH→DATA→TAIL→EMIThunting for F4…parser state: one byte in, one decision, never a guess
LD2410 frame on the wire: 256,000 baud, ~24 bytes per reportF4F3F2F10D0001AA55··F8F7F6F5headerlengthdata: target state, distance, energy…tailSEEK→HEADER→LENGTH→DATA→TAIL→EMITheader matched → HEADERparser state: one byte in, one decision, never a guess
LD2410 frame on the wire: 256,000 baud, ~24 bytes per reportF4F3F2F10D0001AA55··F8F7F6F5headerlengthdata: target state, distance, energy…tailSEEK→HEADER→LENGTH→DATA→TAIL→EMITlength = 13 data bytesparser state: one byte in, one decision, never a guess
LD2410 frame on the wire: 256,000 baud, ~24 bytes per reportF4F3F2F10D0001AA55··F8F7F6F5headerlengthdata: target state, distance, energy…tailSEEK→HEADER→LENGTH→DATA→TAIL→EMITcollecting… no interpretation yetparser state: one byte in, one decision, never a guess
LD2410 frame on the wire: 256,000 baud, ~24 bytes per reportF4F3F2F10D0001AA55··F8F7F6F5headerlengthdata: target state, distance, energy…tailSEEK→HEADER→LENGTH→DATA→TAIL→EMITtail matched → frame completeparser state: one byte in, one decision, never a guess
LD2410 frame on the wire: 256,000 baud, ~24 bytes per reportF4F3F2F10D0001AA55··F8F7F6F5headerlengthdata: target state, distance, energy…tailSEEK→HEADER→LENGTH→DATA→TAIL→EMITbad tail → drop, re-sync at next F4parser state: one byte in, one decision, never a guess

Animated diagram

The OTA conversation: the client sips, never gulps

OTA clientproduct · EP 5Networkthe meshOTA serverNCP DK + nrfutilMatch Descriptor Request (broadcast), who serves 0x0019?Match Descriptor Response. "I do"
OTA clientproduct · EP 5Networkthe meshOTA serverNCP DK + nrfutilQuery Next Image: mfr 0x127F, type 0x0141, v0x01020100…SUCCESS: v0x01030000 available
OTA clientwriting slot 1Networkthe meshOTA serverNCP DK + nrfutilImage Block Request (offset 0, ≤64 B)Image Block Response (data)Image Block Request (offset 64…)block 12 of ~6,800
OTA clientstill sensing ✓Networkthe meshOTA serverNCP DK + nrfutilImage Block Request (offset ~278,000…)Image Block Response (data)≈ 63%, and the room is still being watched
OTA clientrebooting…MCUbootborder guardOTA serverjob doneUpgrade End Request, SUCCESSUpgrade End Response, nowreset → verify signature → swap (trial)
Productv1.3.0 ✓ confirmedNetworkrejoinedOTA serveridleboot_write_img_confirmed()same signed update, no cable. The product is finished

Curriculum

Chapter by chapter

Eleven chapters, one growing firmware. Total: about 28 to 35 hours of building, depending on your pace.

What's inside

Everything is a deliverable

  • 11 hands-on labs: step-by-step builds in a repo layout, each ending in something you can observe working
  • 94 quiz questions with full answer keys: self-check cards, graded as you go
  • 39 diagrams: architecture, join/OTA sequences, parser and bootloader state machines
  • The Zigbee shell toolkit bdb, zdo, zcl taught once, then used to drive and debug every lab
  • Hardware validation checklist: the honest desk checks for every chapter, in one page
  • Stretch exercises. Break it on purpose, extend it, prove you understood
Photo of the nRF52840 DK (PCA10056) development board
Hardware needed

The shopping list

Hardware is not included. It's the standard Nordic desk kit, reusable for every future course on this shelf. One-time cost is roughly US$70 to 110 depending on region.

nRF52840 DK

The board you program: the occupancy sensor itself. (PCA10056)

nRF52840 Dongle ×1 to 2

One runs the coordinator; a second becomes the Chapter 11 sniffer. (PCA10059)

HLK-LD2410

The mmWave presence sensor. Four wires: power, ground, TX, RX.

IKEA TRÅDFRI bulb

Any E26/E27 Zigbee TRÅDFRI bulb, the thing your sensor will switch.

A computer

Windows, macOS or Linux: the pinned toolchain (NCS v3.4.1) installs the same way on all three.

USB cables + jumpers

Two micro-USB cables and a handful of female-to-female jumper wires.

Who it's for

  • Firmware engineers who know basic C and have seen a microcontroller
  • Embedded folks adding Zigbee/Zephyr to a real skill set
  • People who learn by building, and want the artifact to show for it

Who it's not for

  • Complete beginners to C. Learn the language basics first
  • Anyone wanting a video course. This is text, labs and doing
  • Anyone shopping for a certificate. You leave with a product, not a PDF badge
Owner perks

What owners get

Buying the course is the start, not the whole deal. This is what's included with it, and the headline is a human being on the other end of an email.

Headline perk

Lifetime email support

Stuck on a lab, or a concept won't click? Email your question about the course to learnbittoboard@gmail.com and a working engineer answers, within 1 to 2 working days. Support covers questions about the course material and its labs. It is not general consulting, and it does not extend to debugging projects unrelated to the course. For as long as you own the course: no support tiers, no expiry, no ticket bot.

Animated diagrams

The hard ideas (the join handshake, the frame parser, an update crossing the air), are taught as animations you play, pause and step through, not slides that wash past.

94 quiz questions, with answer keys

Every chapter ends in a self-check quiz with full answers, so you find the gaps while they're still cheap to fix.

The troubleshooting appendix

When it breaks. The field guide to the failures this build actually produces: symptom, cause, fix. Kept open beside the labs, it earns its place in the first week.

Exercise-by-exercise code

The product grows one verified step at a time, and the code grows with it, every exercise a buildable checkpoint, nothing dropped on you all at once.

Coming soon

More perks on the way

Owning a course here will keep getting better. More owner perks are in the works, and when they land, owners get them, free.

Course FAQ

Before you ask

How much experience do I need?

Basic C and having seen a microcontroller is enough. Everything Zephyr and Zigbee is taught from zero (devicetree, Kconfig, clusters, all of it), but the course won't teach you what a pointer is.

Which SDK version does it use?

One pinned stack, on purpose: nRF Connect SDK v3.4.1 with the Zigbee R23 add-on v1.4.0 (Zephyr 4.4.2). Chapter 1 installs exactly that. Pinning means every chapter builds the same bits on your machine as on ours.

How long do I have access?

Access doesn't expire. Buy once, build at your pace, come back whenever, including course updates.

What if I get stuck?

Every lab ends in observable behaviour ("you are done when…"), the join-failure field guide covers the common traps, and email support is included. Write to learnbittoboard@gmail.com and a working engineer answers, not a bot.

Can I do it without the IKEA bulb?

Chapters 1 to 7 work with just the DK and dongle. The bulb is where reporting and binding become real (Chapter 8 on), so you'll want one by then. Any Zigbee 3.0 bulb works; the course uses TRÅDFRI because it's cheap everywhere.

Zigbee, Engineered
US$79 one-time
  • All 11 chapters, labs, quizzes and diagrams
  • Lifetime access in the online player, nothing to install, read anywhere
  • Hardware validation checklist
  • Email support from the instructor
  • Free updates to the course
Get the course

Secure checkout is handled by Stripe. The refund policy (14 days, no questions) applies from day one.

About the instructor

A working embedded firmware engineer with 10 years in the field. The course is the desk manual he wishes he'd had: every pattern in it is one he uses in products.

More on the About page →

The project · CI/CD · Docker · Jenkins · GitHub Actions

Embedded DevOps, Engineered

Your firmware builds on your machine, on your day, with your cable plugged in. The project: build the machine that builds it, a real CI/CD pipeline around the occupancy-sensor product from Course 1 (supplied as a finished snapshot). A pinned Docker toolchain, a Jenkins pipeline that tests, signs and ships every commit, and a hardware-in-the-loop rig where CI touches real silicon. The guided lessons are the vehicle; the pipeline is what you keep.

11 chapters 28 to 33 hours Intermediate Built on the Zigbee course's product

Chapter 1 is free with a free account. Read chapter 1

Preview chapter 1
A working laptop with a code editor open on the screen
The problem

"Works on my machine" is not a build system

The usual way

  • One senior engineer's laptop is the release process, and it just went on holiday
  • "Which SDK version built this binary?": archaeology, every field failure
  • Tests run when someone remembers; signing happens by hand, at midnight
  • A broken commit is discovered at integration. Weeks after it landed

This course

  • The toolchain is a pinned Docker image. The same bytes build everywhere, provably
  • Every commit is built, tested and gated by a pipeline that lives with the product
  • The pipeline signs what it ships, from a credential store, never a key on a laptop
  • Releases assemble themselves from a git tag, manifest and checksums included
What you build

One product, one pipeline

The pipeline wraps the occupancy sensor from Zigbee, Engineered: the same nRF52840 firmware, the same signed OTA packages. You containerise its toolchain, stand up Jenkins in Docker, grow the pipeline stage by stage (build → test → sign → release), finish with hardware-in-the-loop flashing on a native agent, then translate the whole thing to GitHub Actions and compare the two homes honestly.

The pipeline takes shape

Checkout, containerised build, archived artifacts. (Chapter 5)

new commit found

container removed;
build/ remains in the workspace

any step exits non-zero

Commit pushed to GitHub

Jenkins polls the repo
(every ~2 min — see next section)

Checkout from SCM
ON: the Jenkins agent's workspace
Fetch Jenkinsfile, check out the commit

Stage: Build firmware
INSIDE: a container started from
toolchain:1.0.0-ncs3.4.1
workspace mounted at the same path,
NCS workspace mounted at /ws

west build — pristine, sysbuild,
nrf52840dk/nrf52840
outputs written to $WORKSPACE/build

Stage: Archive
ON: an ordinary agent (agent any)
— no container

Five artifacts copied to the build's
record, each fingerprinted

Build: SUCCESS (green)
artifacts downloadable from
the build's page

Build stage FAILED (red)
Archive stage never runs

Build: FAILURE (red)
no artifacts attached —
evidence lives in the console log

The test pyramid, on a chip

Host tests, gates, and what runs where. (Chapter 6)

HARDWARE-IN-THE-LOOP — Chapter 10
the real DK, the real sensor, a robot flashes and interrogates
FEW tests · minutes each · catches what only silicon can

SIMULATED INTEGRATION
whole subsystems composed on native_sim
SOME tests · seconds each · catches wiring between units

HOST UNIT TESTS — this chapter
one pure module, fed bytes from arrays
MANY tests · milliseconds each · catches logic errors

GATES — this chapter
format check + static analysis: nothing runs at all
EVERY commit · seconds · catches style drift and a class of bugs

CI touches real silicon

The HIL agent: flash, boot, assert over serial. (Chapter 10)

The desk — the rig is furniture, not a laptop accessory

USB — program + reset
(nrfutil device)

USB serial, 115200
(pyserial)

UART — sensor frames

assigns the HIL stage
to label 'hil' only

all earlier stages run elsewhere:
build agents, inside the toolchain image

Rig machine (Linux)
Jenkins agent process, label: hil
1 executor · venv: pytest + pyserial
nrfutil (native, in PATH)

nRF52840 DK
permanently attached by USB
J-Link: flashing path
CDC ACM: serial console /dev/ttyACM0

HLK-LD2410
UART, as wired in Course 1

Jenkins controller
(Docker Compose, Ch 4)

Read chapter 1 free with a free account
Watch it move

Real animations from inside the lessons

A pipeline is a thing that moves. Stages run, gates stop the line, image layers stack. The lessons animate those flows and hand you the controls. These three are the actual chapter animations, untouched: press play, or step through them frame by frame.

Animated diagram

The pipeline flows, and a red stage stops the line

build #7, the first fully green pipelineCheckout✓Build✓Archive✓5 artifacts filedthe Jenkinsfile lives in the repo. The pipeline is code
a broken commit landsCheckout✓Buildrunning…Archivewaitingmain.c:92:2: error: #error Deliberate Chapter 1 failure…
red means stop, nothing ships from a failed stageCheckout✓Build✗ failedArchivewaitingskipped: earlier failurethe pipeline is a referee, not a cheerleader
fix, push, greenCheckout✓Build✓Archive✓red caught it in minutes, at the cheapest moment

Animated diagram

Layers: the stack is the cache

one Dockerfile, five layers: built bottom-upapp sourcechanges every commitwest workspace + moduleschanges rarelyZephyr run-test requirementspinnedNCS toolchain + SDKthe slow layerbase OSpinnedDockerfileFROM baseinstall toolchainrun-test reqswest init + updateCOPY app source
one Dockerfile, five layers: built bottom-upapp sourcechanges every commitwest workspace + moduleschanges rarelyZephyr run-test requirementspinnedNCS toolchain + SDKthe slow layerbase OSpinnedDockerfileFROM baseinstall toolchainrun-test reqswest init + updateCOPY app source
one Dockerfile, five layers: built bottom-upapp sourceCACHE HIT ✓west workspace + modulesCACHE HIT ✓Zephyr run-test requirementsCACHE HIT ✓NCS toolchain + SDKCACHE HIT ✓base OSpinnedDockerfileFROM baseinstall toolchainrun-test reqswest init + updateCOPY app source
one Dockerfile, five layers: built bottom-upapp sourceCACHE HIT ✓west workspace + modulesCACHE HIT ✓Zephyr run-test requirementsCACHE HIT ✓NCS toolchain + SDKCACHE HIT ✓base OSpinnedDockerfileFROM baseinstall toolchainrun-test reqswest init + updateCOPY app source

Animated diagram

Sign in the pipeline; verify like an enemy

the key shows up for work; it does not move inzephyr.binunsigned🔑 credential storemcuboot-product-keyimgtool signheader + signature
signed, then verified, before archive, every timezephyr.signed.binheader 1.3.1+5 · signature in trailerimgtool verify: three ways this can go:
verdict 1 of 3: the honest image✓ Image was correctly validatedarchive proceeds: manifest, artifacts, the lot
verdict 2 of 3: the wrong key✗ No signature found for the given keydifferent failure, different fix: check the credential, not the code
verdict 3 of 3: one byte different✗ Image has an invalid hashtrust, then verify, then archive, in that order, always

Curriculum

Chapter by chapter

Eleven chapters, one growing pipeline. Total: about 28 to 33 hours of building, depending on your pace. Chapter 1 starts with the ideas (what embedded DevOps is, what it buys an embedded team, and why learning it early compounds for a whole career), before any tooling appears.

What's inside

Everything is a deliverable

  • 11 hands-on labs: every lab run and verified in the course's own build environment before publication, and the build log is quoted in the course. Steps that need physical hardware or a hosted account (the Chapter 10 flash, the Chapter 11 release publish) are written as exact desk procedures in the lessons, labeled as such, for your own desk pass
  • 90 quiz questions with full answer keys: self-check cards, graded as you go
  • 20 diagrams. Pipeline flows, the test pyramid, the HIL rig, the release path
  • A real product repo: the Course 1 firmware, its Jenkinsfile, its tests and its release tooling, growing chapter by chapter
  • Reproducibility, measured: byte-level comparisons of host vs container builds, including the one honest exception and why it happens
  • Stretch exercises. Break the pipeline on purpose, extend it, prove you understood
Embedded DevOps, Engineered
US$79 one-time
  • All 11 chapters, labs, quizzes and diagrams
  • Lifetime access in the online player, nothing to install, read anywhere
  • The full product + pipeline repo layout
  • Email support from the instructor
  • Free updates to the course

Chapter 1 is free with a free account. Read chapter 1

This course is not on sale yet. Join the notify list above and Chapter 1 is free with a free account in the meantime.

Before you start

You do not need Course 1 first: the product repo supplied with this course contains the finished Course 1 firmware snapshot, and the pipeline is built around that snapshot. Course 1 is recommended background; if you built the product yourself there, yours is the same code. You need a machine that runs Docker, and a free GitHub account for Chapter 11. Jenkins, Docker and every other tool in the course is free. The only paid item at launch is your own domain, if you want one.

Owner perks

What owners get

Buying the course is the start, not the whole deal. This is what's included with it, and the headline is a human being on the other end of an email.

Headline perk

Lifetime email support

Pipeline red at midnight, or a gate you don't trust? Email your question about the course to learnbittoboard@gmail.com and a working engineer answers, within 1 to 2 working days. Support covers questions about the course material and its labs. It is not general consulting, and it does not extend to debugging projects unrelated to the course. For as long as you own the course: no support tiers, no expiry, no ticket bot.

Animated diagrams

The moving parts (stages flowing, gates stopping the line, image layers stacking, a signature being checked), are taught as animations you play, pause and step through.

90 quiz questions, with answer keys

Every chapter ends in a self-check quiz with full answers, so you find the gaps while they're still cheap to fix.

The troubleshooting appendix

When it breaks. The field guide to the failures this build actually produces: symptom, cause, fix. Kept open beside the labs, it earns its place in the first week.

Exercise-by-exercise code

One real product repo (the firmware, its Jenkinsfile, its tests and its release tooling), growing a verified step at a time, chapter by chapter.

Coming soon

More perks on the way

Owning a course here will keep getting better. More owner perks are in the works, and when they land, owners get them, free.

Course FAQ

Before you ask

Do I need Course 1 first?

No. The pipeline is built around the occupancy-sensor product, and a finished snapshot of that firmware is supplied in this course's own repo, so you can start here. Course 1 is recommended background: it explains how the firmware was built. The DevOps concepts themselves are taught from zero.

Which tools, exactly?

Docker and Docker Compose from Chapter 2, Jenkins 2.580.1 LTS (pinned, run in Docker) as the anchor, Twister for tests, imgtool for signing, and GitHub Actions + GHCR for the final translation. Everything is free tooling; no paid CI minutes are required to follow along.

Does the hardware-in-the-loop chapter need the desk kit?

Yes. Chapter 10 flashes a real nRF52840 DK from a Jenkins agent. Everything up to the physical flash is verified in the course's own build log; the flash itself is a desk step with exact expected output, like Course 1's hardware checks.

How long do I have access?

Access doesn't expire. Buy once, build at your pace, come back whenever, including course updates.

The project · Zigbee · Zephyr · nRF52840

Zigbee, Engineered: Part 2

The remote that lives on a coin cell. Part 1 built the always-on half of a Zigbee network: a wall powered sensor and router that never sleeps. This is the other half, the half most real products are made of: a battery remote that sleeps with its radio off, wakes when you press it, toggles Part 1's bulb, reports its own battery, and takes its firmware updates in its sleep.

11 chapters + appendix 30 to 36 hours Advanced Builds on Part 1

Chapter 1 is free with a free account. Read chapter 1

Preview chapter 1
Photo of the nRF52840 DK (PCA10056). The board Part 2 is built on
Why a Part 2

Part 1 was the always-on world. This is the battery world.

Part 1's device plugs into the wall. Its radio listens around the clock, it routes other devices' traffic, and its power budget is whatever the mains feels like providing. You could learn commissioning, clusters, reporting and OTA updates there, and you did, without power ever once being the constraint.

Almost nothing you will ever ship works like that. Real Zigbee products run on coin cells and AA pairs, in remotes, buttons, contact sensors, leak detectors and thermostats, and they live or die on a number Part 1 never had to care about: average current. A device that draws 15 milliamps awake has to be asleep about 99.9 percent of its life to survive a year on a CR2032. Designing for that is not Part 1 with a battery bolted on. It is a different discipline, with different failure modes, different debugging, and a different definition of done.

The always-on world (Part 1)

  • Wall power: the radio listens around the clock
  • Routes other devices' traffic
  • Power is never the constraint
  • Updates arrive whenever the server offers them

The battery world (Part 2)

  • A coin cell: average current is the product spec
  • Asleep about 99.9 percent of its life, radio off
  • Reachable only in the moments it chooses to poll
  • Updates are scheduled around sleep, and resume after it

Part 2 exists to teach that discipline on the same desk, the same network and the same bulb, so the only new variable is the one that matters. The skill at its centre is power profiling: predicting a device's energy budget from a datasheet before it exists, measuring the real current properly (which on a development kit is its own skill: know what your header actually measures), and closing the gap between the two numbers until the prediction and the measurement agree. That loop, datasheet to budget to measurement to reckoning, is how battery products are actually engineered, quoted in job requirements, and almost never taught end to end. The skills in this course are the ones used to ship battery products that last years, not weeks.

And the budget is not decoration. Chapter 1 predicts about 13 µA average, about 1.7 years on the cell, as a labelled design target. Chapter 5 measures against it, Chapter 11 closes it with real numbers from your own bench, and every later chapter prices its feature in the same currency: polling intervals, debounce windows, even the LED blink that says "sent".

What you build

One remote, the whole battery discipline

A battery push-button controller on the nRF52840 DK: a Zigbee sleepy end device that joins the network Part 1 built, binds to the same IKEA bulb on its own endpoint, and drives its On/Off cluster (short press toggles, hold dims, through Level Control). It wakes from a GPIO interrupt, never from a polling loop; it classifies presses by timestamp; it measures its own supply with the SAADC and reports battery volts and percentage as standard ZCL Power Configuration attributes; it has a five-second-hold ship mode built on System OFF with GPIO sense wake; and it discovers, downloads and applies signed firmware updates on a wake schedule, resuming across sleeps, all before the finale takes it untethered onto a CR2032 in the DK's own holder.

The poll, up close

Data requests, parent buffering, and how frames reach a device that is usually deaf. (Chapter 4)

NetworkParentSleepy switchNetworkParentSleepy switchasleep, radio offbuffered. The parent waits.acts on it, back to sleepasleep again. Total cost, a few ms awake.command for the switch arrivesdata request (the poll)ACK, frame pending = 1the buffered commandACKnext poll, one interval laterACK, frame pending = 0

The device reports its own battery

SAADC to standard Power Configuration attributes. (Chapter 6)

VDD rail
about 2.4 to 3.3 V
in service

Internal channel
gain 1/6

SAADC, 12 bit
reference 0.6 V
internal

Counts
0 to 4095

VDD equals counts
multiplied by
3.6 V / 4096

Power Configuration
cluster attributes

Updates that survive naps

Block transfer across sleeps, resume, apply on wake. (Chapter 10)

yes

no

yes

no

Wake: button, poll timer or FOTA cycle

MAC poll to parent

Parent holds a block response?

Receive blocks, write to secondary slot in external flash

Download complete?

Fast poll while download is active, bounded

Upgrade End, library reports FINISHED

App reboots on this wake

MCUboot verifies, swaps, boots trial image

Join, confirm, resume normal sleepy life

Sleep. Server waits. Progress persists if resume is on.

Read chapter 1 free with a free account
The advanced layer

What "advanced" means here, in the open

This is an advanced course, and the word is itemised rather than decorative. Some of it is core and hands-on; some is taught as far as it honestly goes on this hardware, and labelled that way, because Part 1's honesty rule about evidence applies to Part 2's claims too.

Core, built and measured

  • End-device resilience: parent loss, rejoin, orphaning and recovery (Chapter 4)
  • Retained RAM: what survives the deepest sleep, and what you do when the board gives you none (Chapter 8)
  • APS frame counters, install codes and network key updates: the trust layer of a real deployment (Chapter 11)
  • Press classes as a state machine: toggle, dimming, and the ship-mode hold, costed in microamps (Chapter 7)

Taught, with honest limits

  • Touchlink commissioning: taught in Chapter 11, with a stretch exercise; it is a proximity phone-style flow that stays optional in most deployments, and the course says so
  • Green Power: covered as a concept, with the reason stated plainly: Green Power proxy and sink infrastructure is beyond what this bench can honestly demonstrate
  • Bench measurements: the course's own current figures are design targets until the author's hardware pass lands; your measurements on your bench are the course's real numbers, and the chapters are built around taking them
Watch it move

Real animations from inside the lessons

A sleepy device's life is timing: wakes, polls, naps, an update crossing the air in bursts between sleeps. The lessons animate those flows and hand you the controls. These three are the actual chapter animations, untouched: press play, or step through them frame by frame.

Animated diagram

Press to light, in four moves

asleep: radio off, waiting on a pin, not a timerSleepy deviceradio off, ~2 µAParentbuffers for itIKEA bulboffnothing is being polled. The press will do the waking.
the press wakes it: interrupt, timestamp, classifySleepy devicewake on GPIOParentbuffers for itIKEA bulbofflatency clock starts at the edge, not at the send
toggle sent, bound straight to the bulbSleepy devicesending toggleParentdeliveringIKEA bulboffthe parent is the postman: it holds and forwards
light on, device asleep: the whole trip, budgetedSleepy deviceback to sleepParentdeliveredIKEA bulbONwake, send, delivery, sleep: four moves, one number to defend

Animated diagram

One poll cycle

asleep and unreachable: the network waits at the parentSleepy deviceradio offParentholding its maila sleepy device has no inbox of its own. The parent is the inbox.
wake, and ask: a data request to the parentSleepy devicedata requestParentchecking the queuethe poll is a question, not a download
nothing pending: back to sleep in millisecondsSleepy deviceasleep againParentnothing pendingthe common case is empty, and it is priced that way in the budget
frame pending: stay awake, receive, then restSleepy devicereceivingParentframe pendingfast poll for conversations, long poll for silence

Animated diagram

Blocks at poll cadence

blocks arrive in bursts, between sleepsSleepy devicereceiving blocksimage: blocks 1 to ninto the secondary slotprogress is stored, not remembered: it survives the naps
asleep mid-download: normal, not an errorSleepy deviceasleep, progress keptresume stateoffset + image identitythe server holds its place; the client holds its offset
the reset drill: resume, not restartSleepy devicereset mid-downloadresumes at the offsetverified in the lessonyou will do this reset yourself, and watch it recover
apply on wake, swap, confirm, with rollback beneathSleepy devicerunning the new imageMCUboot swap doneunconfirmed images revertthe update waited for naps, and the device never missed one

Curriculum

Chapter by chapter

Eleven chapters and a troubleshooting appendix. Total: about 30 to 36 hours of building, depending on your pace. One number runs through all of it: Chapter 1 predicts about 13 µA average; Chapter 5 measures against it; Chapter 11 closes the budget with your own numbers.

What's inside

Everything is a deliverable

  • 11 hands-on labs: every lab builds in the pinned stack before publication, and the build log is quoted in the course. Measurements, the coin-cell finale and other physical steps are written as exact desk procedures in the lessons, labeled as such, for your own bench pass
  • 100 quiz questions with full answer keys: self-check cards, graded as you go
  • 17 diagrams, plus an animated diagram in every chapter: poll cycles, power paths, press classes, the sleepy OTA schedule
  • A running energy budget: predicted in Chapter 1, measured in Chapter 5, closed in Chapter 11
  • One public repo, exercise by exercise: github.com/learnbittoboard/zigbee-engineered-part2
  • Stretch exercises: poll schedules pushed to their budget limit, touchlink, whole-board current accounting, ship-mode amnesia
Zigbee, Engineered: Part 2
US$79 one-time
  • All 11 chapters, labs, quizzes and diagrams
  • Lifetime access in the online player, nothing to install, read anywhere
  • The full course repo, exercise by exercise
  • Email support from the instructor
  • Free updates to the course

Chapter 1 is free with a free account. Read chapter 1

This course is not on sale yet. Join the notify list above and Chapter 1 is free with a free account in the meantime.

Before you start

Part 2 continues Part 1's network, bulb and desk, so Part 1 comes first: you need its working network (coordinator plus at least one router) and its IKEA bulb. The hardware delta is one pack of CR2032 cells. Everything is built on the nRF52840 DK: its coin cell holder powers the untethered finale, so the DK is the platform the course completes on. A Nordic PPK2 is optional: the measurement chapters are written meter-agnostic, with a full path that needs only a multimeter.

Owner perks

What owners get

Buying the course is the start, not the whole deal. This is what's included with it, and the headline is a human being on the other end of an email.

Headline perk

Lifetime email support

Stuck on a poll interval, or a sleep current that will not come down? Email your question about the course to learnbittoboard@gmail.com and a working engineer answers, within 1 to 2 working days. Support covers questions about the course material and its labs. It is not general consulting, and it does not extend to debugging projects unrelated to the course. For as long as you own the course: no support tiers, no expiry, no ticket bot.

Animated diagrams

The moving parts (poll cycles, the press-to-light trip, blocks arriving at poll cadence, the untethered finale), taught as animations you play, pause and step through.

100 quiz questions, with answer keys

Every chapter ends in a self-check quiz with full answers, so you find the gaps while they're still cheap to fix.

The troubleshooting appendix

When it breaks. The field guide to this build's real failures: the silent snippet, the orphaned end device, the quietly tethered finale. Symptom, cause, fix, kept open beside the labs.

Exercise-by-exercise code

One public repo, one verified step at a time: the sleepy switch firmware growing chapter by chapter, exactly as the lessons build it.

Coming soon

More perks on the way

Owning a course here will keep getting better. More owner perks are in the works, and when they land, owners get them, free.

Coming soon

The switch, as a product

The device you build in this course lives on a development kit, headers and all. A finished version, an enclosed, battery powered Zigbee sleepy switch you can stick on a wall and use, is in development here as a Bit to Board product. Course owners will hear about it first.

No date, no price, nothing to pre-order. When it exists, it will be announced here and to the newsletter.

Course FAQ

Before you ask

Do I need a PPK2?

No. The measurement chapters are written meter-agnostic, and there is a complete path that needs only a multimeter (the current-range series method on P22). The PPK2, or any proper current profiler, makes Chapter 5 easier and is used for the average-current work; if you buy one instrument for embedded power work, that is the one. But it is never a course requirement.

What new hardware do I need beyond Part 1?

A pack of CR2032 cells. That is the delta. Everything else (the DK, the dongle coordinator, the IKEA bulb, the sniffer) carries over from Part 1.

Do I really need Part 1 first?

Yes, as a working setup: Part 2's device joins Part 1's network and binds to Part 1's bulb, and Chapters 4 and 9 assume Part 1's endpoints and OTA machinery are familiar. The sleepy, power and measurement material itself is taught from zero.

Can I build it on a smaller board, like a XIAO?

The labs complete on the nRF52840 DK: its coin cell holder powers the untethered finale, and its headers and bridges are what the measurement chapters are written against. The firmware knowledge transfers to any nRF52840 board; the course's procedures, checkpoints and finale are DK procedures.

Course player
Chapter 1 of 11~2 to 3 h

Setup the pinned way, and your first debug session

Worked through the lab and the quiz? Mark it done.
Chapter 2 of 11~3 to 3½ h

Zephyr essentials (only what this course uses)

Worked through the lab and the quiz? Mark it done.
Chapter 3 of 11~2 to 2½ h

GPIO and interrupts, done properly

Worked through the lab and the quiz? Mark it done.
Chapter 4 of 11~3 to 3½ h

Talk to the LD2410: a UART driver from the datasheet

Worked through the lab and the quiz? Mark it done.
Chapter 5 of 11~2½ to 3 h

Zigbee from zero, and the coordinator on a dongle

Worked through the lab and the quiz? Mark it done.
Chapter 6 of 11~2 to 2½ h

Commissioning: our device and an IKEA bulb join the network

Worked through the lab and the quiz? Mark it done.
Chapter 7 of 11~2½ to 3½ h

Write the ZCL Occupancy Sensing endpoint & cluster

Worked through the lab and the quiz? Mark it done.
Chapter 8 of 11~3 to 3½ h

Reporting & binding: occupancy switches the bulb

Worked through the lab and the quiz? Mark it done.
Chapter 9 of 11~2½ to 3 h

MCUboot: signing, swap, and rollback

Worked through the lab and the quiz? Mark it done.
Chapter 10 of 11~2½ to 3 h

OTA updates over the Zigbee network

Worked through the lab and the quiz? Mark it done.
Chapter 11 of 11~3 to 3½ h

See it on the air: the sniffer capstone

Worked through the lab and the quiz? Mark it done.
Appendix · referencekeep this open while you work

Appendix: When it breaks, the troubleshooting field guide

Chapter 1 of 11~1½ to 2 h

Why embedded CI is different, and the baseline you will automate

Worked through the lab and the quiz? Mark it done.
Chapter 2 of 11~2½ to 3 h

Docker for embedded engineers

Worked through the lab and the quiz? Mark it done.
Chapter 3 of 11~2½ to 3 h

The pinned toolchain image, production grade

Worked through the lab and the quiz? Mark it done.
Chapter 4 of 11~2 to 2½ h

Jenkins, standing it up the Docker way

Worked through the lab and the quiz? Mark it done.
Chapter 5 of 11~2 to 2½ h

Your first declarative pipeline: CI builds the firmware

Worked through the lab and the quiz? Mark it done.
Chapter 6 of 11~3½ to 4 h

The test pyramid on a chip: automated tests and gates

Worked through the lab and the quiz? Mark it done.
Chapter 7 of 11~2½ to 3 h

Build matrix, versioning, and artifacts that mean something

Worked through the lab and the quiz? Mark it done.
Chapter 8 of 11~3 to 3½ h

Secrets and signing: the pipeline signs what it ships

Worked through the lab and the quiz? Mark it done.
Chapter 9 of 11~2½ to 3 h

Release pipelines: tag it and it ships

Worked through the lab and the quiz? Mark it done.
Chapter 10 of 11~3½ to 4 h

Hardware-in-the-loop: CI touches real silicon

Worked through the lab and the quiz? Mark it done.
Chapter 11 of 11~2½ to 3 h

The same pipeline, in GitHub Actions

Worked through the lab and the quiz? Mark it done.
Appendix · referencekeep this open while you work

Appendix: When it breaks, the troubleshooting field guide

Chapter 1 of 11~2 to 2½ h

The sleepy end device: what changes when the battery is the boss

Worked through the lab and the quiz? Mark it done.
Chapter 2 of 11~2½ to 3 h

Your first sleepy build

Worked through the lab and the quiz? Mark it done.
Chapter 3 of 11~2½ to 3 h

Buttons that do not burn the battery

Worked through the lab and the quiz? Mark it done.
Chapter 4 of 11~3 to 3½ h

Polling, parents, and staying joined

Worked through the lab and the quiz? Mark it done.
Chapter 5 of 11~3 to 3½ h

Measure it: the budget meets reality

Worked through the lab and the quiz? Mark it done.
Chapter 6 of 11~3 to 3½ h

Battery health is a feature

Worked through the lab and the quiz? Mark it done.
Chapter 7 of 11~2½ to 3 h

Polish: dimming, feedback, and manners

Worked through the lab and the quiz? Mark it done.
Chapter 8 of 11~2½ to 3 h

System OFF: the deepest sleep

Worked through the lab and the quiz? Mark it done.
Chapter 9 of 11~2 to 2½ h

Sleepy OTA, part 1: an update that waits for naps

Worked through the lab and the quiz? Mark it done.
Chapter 10 of 11~3 to 3½ h

Sleepy OTA, part 2: download across sleeps, apply on wake

Worked through the lab and the quiz? Mark it done.
Chapter 11 of 11~3 to 3½ h

Capstone: the untethered remote

Worked through the lab and the quiz? Mark it done.
Appendix · referencekeep this open while you work

When it breaks: the troubleshooting field guide

About

A school with a workbench, not a lecture hall

Bit to Board · Learn embedded systems through projects.

Bit to Board exists because of a gap every embedded engineer knows: the internet is full of explanations, and nearly empty of guided builds. You can read about Zigbee clusters for a week and still freeze the first time a device refuses to join your network. The fix isn't more content. It's a project with stakes, in the right order, with someone checking the path ahead of you.

So every course here is one real product, built end to end: the sensor, the network, the update mechanism, the packet captures. Concepts show up when the build demands them. Shortcuts that would embarrass you in a product review don't make the cut. Debounce is done properly, updates are signed, rollback is rehearsed before it's needed.

The instructor

The courses are written and maintained by a working embedded firmware engineer with ten years in the field, consumer and industrial products, Nordic and Zephyr daily, and a home lab that has strong opinions about lighting. No presenter-reading-a-script, no outsourced content farm: the person who answers support email is the person who built every lab.

Verified, not vibes

Every code step in every course is compiled from a pristine build against the pinned SDK before publication, 25 verified build steps in the Zigbee course alone. What's verified is stated plainly, and so is the boundary: on-hardware behaviour is checked against a written desk checklist, which ships inside the course so you can re-run every check yourself.

Browse the courses

FAQ

Questions, answered straight

How do I get access after paying?

Create a free account first, then buy the course from its course page. Stripe handles the payment, and your account gains access as soon as checkout completes. Sign in on any device to keep reading, and your progress follows your account.

How long do I have access?

Forever, as far as we're concerned. Courses are buy-once: no subscription, no expiry, and updates to a course you own are free.

What hardware do I need?

It depends on the course. Each course page lists its exact kit. The Zigbee course uses an nRF52840 DK, one or two nRF52840 Dongles, an HLK-LD2410 sensor and an IKEA TRÅDFRI bulb (roughly US$70 to 110 total). The same desk kit carries into future courses. Hardware is never included in the course price.

Do I need experience?

Basic C and having seen a microcontroller is the floor for the Zigbee course. Everything platform-specific (Zephyr, Zigbee, the tooling) is taught from zero inside the course. If you can't yet read C comfortably, start there first. We'll be here.

Is there support if I get stuck?

Yes. Email support from the instructor is included with every paid course. Labs are written to end in observable checkpoints ("you are done when…"), so most stuck moments resolve by checking which checkpoint you haven't reached yet.

What if I get stuck, or have a question about the material?

Ask us. Every paid course includes lifetime email support: email your question about the course to learnbittoboard@gmail.com and a working engineer replies within 1 to 2 working days, for as long as you own the course. Support covers questions about the course material and its labs. It is not general consulting, and it does not extend to debugging projects unrelated to the course. (The support commitment applies from day one.)

What's the refund policy?

14 days, no questions asked. Email us and we refund in full. The full policy is on the Refund Policy page. (Draft until launch. The promise itself won't change.)

Do I get a certificate?

Not a framed one. You leave with something better in an interview: a working product on your desk and a Wireshark capture of it that you can explain frame by frame. If formal certificates matter to your employer, ask us. It's on the roadmap.

Will there be free courses?

Yes. One or two short courses will be free, in the same project-based format. The catalog marks them clearly when they land.

Newsletter

New posts, new courses, first

One email when there is something worth saying: a new Guide from the workbench (two are live today, more are coming), a course launch (Zigbee Part 2 is next), or product news, like the Bit to Board Switch. No weekly noise, no spam, ever. Unsubscribe any time, one click, no hard feelings.

Please enter a valid email address.
Done ✓: Buttondown is confirming your signup (see the window it opened, and your inbox). Then: one email when the next post, course or product lands. Nothing else.

We only ever email about upcoming releases, news and new posts. Your address is never sold or shared.

Contact

Ask a working engineer

Course questions, stuck-on-a-lab questions, "will this work with my board" questions, all welcome.

Prefer plain email? Write to us at learnbittoboard@gmail.com. That's also the course support address: owners get a reply within 1 to 2 working days.

Message noted ✓: thanks for writing. (Preview build: the form validates and records your message here in the page; at launch it delivers straight to the instructor's inbox.)
Please enter your name.
Please enter a valid email address.
Give us a little more detail (at least a sentence or two).

This form wires to email at launch. In this preview build nothing is sent anywhere, submitting shows the confirmation state above.

Legal

Terms of Service

Last updated: draft

⚠ Draft placeholder, to be reviewed and finalized before the site launches and payments go live.

What you're buying

A personal, non-transferable license to access the course(s) you purchased on this site, for your own learning. Course materials (text, diagrams, code samples) may be used in your own projects; they may not be resold, redistributed, or published as a competing course.

Access

Access is tied to your Bit to Board account after payment and does not expire. We may update course content over time; updates to a course you own are included. We aim to keep the course area available continuously but do not guarantee uninterrupted service.

Hardware & safety

Courses involve third-party hardware that you buy separately. Follow the manufacturers' documentation and ordinary electronics safety practice. We are not responsible for damage to hardware, property, or persons arising from following course instructions.

Payments & refunds

Prices are shown in US dollars and charged at checkout by our payment processor. Refunds follow the Refund Policy.

Acceptable use

Don't share access links publicly, scrape the course area, or use the materials to build a competing product. Accounts or links found doing so may be revoked without refund.

Liability

Course content is provided "as is" for education. To the extent permitted by law, our total liability is limited to the amount you paid for the course in question.

Legal

Privacy Policy

Last updated: draft

⚠ Draft placeholder, to be reviewed and finalized before the site launches and payments go live.

What we collect

The minimum to run a course business: your name and email (for access and support), payment records held by our payment processor (we never see or store your full card details), and the messages you send us.

What we don't do

We don't sell your data, run third-party advertising trackers, or profile you across the web. When you are signed in, your course progress is stored with your account so it follows you across devices.

Analytics

If privacy-respecting, aggregate analytics are added at launch, this policy will be updated before they are enabled.

Your rights

Email us to see, correct, or delete the personal data we hold about you. Deleting your purchase email record also ends course access, since the email is your key.

Contact

Privacy questions go through the Contact page.

Legal

Refund Policy

Last updated: draft

⚠ Draft placeholder, to be reviewed and finalized before the site launches and payments go live.

The short version

14 days, no questions asked. If a course isn't for you, email within 14 days of purchase for a full refund.

How it works

Send the request from your purchase email via the Contact page. Refunds go back to the original payment method, typically within 5 to 10 business days depending on your bank. Access ends when the refund is processed.

After 14 days

Refunds after the window are at our discretion, if something genuinely broke on our side (access never worked, content materially different from the course page), tell us and we'll make it right.

Hardware

Hardware is bought from third-party retailers and isn't covered by this policy; their return terms apply.

Account

Sign in

Sign in to read your chapters, keep your progress, and open the courses you own.

Forgot your password?

New here? Create a free account. Chapter 1 of every course is free with one.

Account

Create a free account

A free account opens Chapter 1 of every course, saves your progress across devices, and is the account your purchased courses live on.

Already have an account? Sign in.

Account

Forgot your password

Enter your account email. If the account exists, we email a temporary password that works for 1 hour. Your old password stops working as soon as the temporary one is issued, and you set a new password right after signing in with it.

Remembered it after all? Sign in.

Account

Set a new password

Pick a new password for your account. If you signed in with a temporary password, this step is required before anything else opens.

Checkout

Get the course

One payment, handled by Stripe. Access lands on your account as soon as checkout completes, and it does not expire.

Loading the store…

Account

Your account

Your courses, your access, and your progress, all in one place.

Loading your account…