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.

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 480,516 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). Together with the channel and the key, they are the network's identity card, and the coordinator itself holds short address 0x0000, permanently: the first member, the Trust Center, the device every joiner will receive its key from in Step 6. When later steps have you read neighbour tables full of strangers, 0x0000 is the one address in any Zigbee network you can identify blindfolded. 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 400,264 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.

While it is not yet joined, by the way, the DK does not sit idle or give up: an unjoined router keeps scanning the channels, listening for a network that will have it, indefinitely. That patience is a product feature in the field (a bulb installed before its hub still finds its network when the hub arrives) and a diagnostic gift on the desk: if LED 3 stays dark for a full minute beside a started coordinator, the problem is never "the bulb has not gotten around to it". It is one of the three suspects named below, and it will still be true in an hour.

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.

One more interrogation, because its answer explains Step 5's whole shape. Ask the bulb for its binding table, the stored list of "when X happens, tell Y":

zigbee:~$ zdo mgmt_bind 0x1234

It comes back empty, and the emptiness is the lesson. A binding is how a device knows where its output goes without being told each time: a switch with 0x0006 in its output clusters holds a binding naming the bulb's address and endpoint, and thereafter the switch's stack delivers toggles on its own. This bulb has outputs bound to nobody, and the coordinator's shell holds no binding to it either, which is why the next step types every command by hand, address and all. You are not missing a feature; you are standing exactly where the feature would go, and Step 5 is what the world looks like before it is installed.

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 115,284 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

Before the three passes, dissect one frame properly, because the layer stack stops being a diagram the first time you walk it in a real packet. Select your Toggle to the DK bulb and open it layer by layer, outermost first. The 802.15.4 header is the envelope: source and destination short addresses and the PAN id you chose at formation, the postal service that got the frame across the room and knows nothing of its contents. Inside it, the NWK header is Zigbee's routing layer: it carries the final destination (which, in a bigger network, need not be the radio that physically received the frame) and, on these frames, the security material that lets the receiver verify the network key was used. Inside that, the APS header is the application envelope: profile 0x0104, cluster 0x0006, destination endpoint 1, and source endpoint 64, which you last met as a number in a config file in Step 1; the shell's commands really do leave from the shell's own endpoint, and here is the frame proving it. And innermost, the ZCL payload: a frame-control byte, a sequence number, and command 0x02. Four headers deep, and the payload is a single byte doing exactly what you typed. Every Zigbee frame you will ever debug, including the ones inside commercial products with six-figure test budgets, has this same anatomy; only the innermost bytes change.

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.

After the power cut: the network remembers

One experiment remains, and it is the one that turns today's party trick into a system. Unplug everything: coordinator dongle, DK, lamp. Count to ten. Plug it all back in, coordinator first, and do nothing else. Within a minute the desk is a network again: LED 3 lit on the DK, the IKEA bulb answering toggles, no ritual, no commands from you. Nothing about that is automatic in the sense of free. It is three kinds of memory agreeing with each other.

The coordinator remembers the network: its PAN id, channel, and key survive in non-volatile memory on the dongle, so bdb start after a reset, or plain power returning, resurrects the same network rather than forming a new one. (This is also the trap in the troubleshooting table below: reflash the dongle carelessly and the memory is gone, and from the bulbs' perspective the network they trusted has been replaced by an identical stranger.) The routers remember the network too: each holds the key and its short address assignment, so a returning router does not repeat Step 6's courtship. And the ceremony is visibly shorter: if the sniffer is still listening, capture the recovery and compare it against the join capture frame by frame. A power-restored router performs a rejoin, announcing itself with the key it already holds. There is no Transport Key frame, because there is nothing to transport; the bulb never forgot. That single missing frame, against the capture where it was the centrepiece, is the cleanest possible proof that Zigbee's security state lives on the devices, not in the session.

Run the experiment once with the sniffer on. You now own two captures that between them explain nearly every "it stopped working after the outage" story ever told about a smart home: either the coordinator forgot (new network, old loyal bulbs), or a bulb forgot (full join ceremony, Transport Key and all), or nobody forgot and the network healed before the human finished swearing at it.

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
Wireshark's sniffer interface shows no frames at all, even during a join The sniffer dongle is on the wrong channel, the extcap interface was not the one selected, or the "sniffer" dongle is actually running the coordinator image (two identical dongles, one hasty flash) Set channel 15 in the extcap; check the port's product string ("nRF 802154 Sniffer", not "Zigbee Shell"); reflash the right image to the right dongle, and label them this time
The network works, but every command needs several tries near one particular spot 2.4 GHz congestion in that spot (a Wi-Fi access point on channel 1 or 6 shouting over Zigbee 15), or the bulb is at the edge of range and routing through nothing Move the coordinator or the lamp a metre; consider channel 20 or 25 at the next formation (Step 2's paragraph); range problems on a desk are placement problems, not stack problems

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