zigbee
A from-scratch, pure-Elixir Zigbee stack. No NIFs, no C daemons, just
circuits_uart and binary pattern matching. It runs a Zigbee coordinator on a
Silicon Labs EmberZNet dongle: form a network, pair devices, and read their
clusters (temperature, humidity, switches, …).
{:zigbee, "~> 0.1.0"}
Quick start
The following example shows the flow to open the dongle (eg. ZBT-2), form a network, pair a sensor, and read data from it:
# 1. open the radio and become its event subscriber
{:ok, zb} = Zigbee.start_link(Zigbee.EZSP.Adapter, device: "/dev/ttyACM0", speed: 460_800)
:ok = Zigbee.subscribe(zb, self())
# 2. form a coordinator network (also registers a default Home Automation endpoint)
{:ok, params} = Zigbee.form_network(zb, channel: 15)
#=> {:ok, %{channel: 15, pan_id: 0x63ED, ...}}
# 3. open joining, then put the device (eg. temp sensor) into pairing mode (usually by pressing a 'pair' button)
{:ok, dev} = Zigbee.Interview.open_and_wait(zb)
#=> {:ok, %{node_id: 0xA1B2, eui64: <<...>>}}
# 4. interview it: enumerate endpoints, bind + configure reporting for temp/humidity
{:ok, _summary} = Zigbee.Interview.run(zb, dev.node_id, dev.eui64)
# 5. watch the readings arrive (°C / %)
Zigbee.Interview.collect(zb, 60_000)
#=> [%{cluster: 0x0402, endpoint: 1, value: 21.4, unit: "°C"},
# %{cluster: 0x0405, endpoint: 1, value: 47.8, unit: "%"}]
Usage
Opening a radio
Zigbee.start_link/2 takes a backend module (implementing Zigbee.Adapter) and
its options, and returns a handle used by every other call:
{:ok, zb} = Zigbee.start_link(Zigbee.EZSP.Adapter, device: "/dev/ttyACM0", speed: 460_800)
Zigbee.info(zb) #=> %{protocol_version: 13, stack_version: "7.5.1.0", stack_type: 2}
For the EmberZNet backend, :device is the serial port and :speed the baud rate
(460_800 for the ZBT-2; many other sticks default to 115_200).
Forming a network
Zigbee.form_network/2 runs the full centralized (trust-center) coordinator setup and
returns once the network is up. Endpoints are registered as part of forming
(they must exist before the network comes up), so a plain form_network/1 is enough:
{:ok, params} = Zigbee.form_network(zb, channel: 15)
Options: :channel (11..26, default 15), :pan_id, :extended_pan_id,
:tx_power, :network_key, and :endpoints (:default, :none, or a list of
{endpoint, profile, device_id, in_clusters, out_clusters}).
Pairing a device
Zigbee.Interview orchestrates the whole join → interview → bind → report flow.
The process that calls it must be the adapter's subscriber (Zigbee.subscribe/2).
# opens the join window and blocks until a device joins (default 180s)
{:ok, dev} = Zigbee.Interview.open_and_wait(zb)
# enumerate the device's endpoints/clusters, then bind + configure reporting
# on every temperature (0x0402) and humidity (0x0405) cluster it exposes
{:ok, summary} = Zigbee.Interview.run(zb, dev.node_id, dev.eui64)
#=> {:ok, %{endpoints: [1], descriptors: [...], bindings: [...]}}
run/4 takes :min_interval / :max_interval (reporting bounds in seconds).
Reading reports
After run/4 has configured reporting, the device pushes updates on its own. Use
collect/2 to gather and decode them into engineering units:
Zigbee.Interview.collect(zb, 60_000)
#=> [%{cluster: 0x0402, endpoint: 1, value: 21.4, unit: "°C"}, ...]
Sending your own commands
For anything the Interview helpers don't cover, build a raw APS payload with the
spec codecs and send it with Zigbee.send_aps/7:
# read the Basic cluster's manufacturer + model (attrs 0x0004, 0x0005) on endpoint 1
frame = Zigbee.ZCL.read_attributes(_seq = 1, [0x0004, 0x0005])
{:ok, _aps_seq} = Zigbee.send_aps(zb, dev.node_id, 0x0104, 0x0000, 1, frame)
# the reply arrives as {:zigbee, :message, %Zigbee.Message{}}; decode it with Zigbee.ZCL.decode/1
Handling events yourself
The subscriber receives backend-neutral events. Zigbee.Interview consumes these
for you, but you can handle them directly for custom flows:
receive do
{:zigbee, :device_joined, %{node_id: id, eui64: eui}} -> ...
{:zigbee, :message, %Zigbee.Message{cluster: c, payload: p}} -> Zigbee.ZCL.decode(p)
end
Examples
examples/pair_and_read.exs is a complete, runnable
demo: form a network, pair one device, and print its readings live.
# defaults to /dev/ttyACM0 @ 460800 baud, channel 15
mix run examples/pair_and_read.exs
# override via env vars (e.g. a ZBT-2 on macOS)
ZBT_DEVICE=/dev/cu.usbmodem1CDBD45F0F5C1 ZBT_CHANNEL=20 mix run examples/pair_and_read.exs
examples/sensor_hub.exs shows how to run this from a
supervised GenServer that owns the radio, subscribes to events, and reacts to joins
and reports in handle_info/2. It also shows the one gotcha: handle the event stream
reactively rather than calling the blocking Zigbee.Interview.* helpers from inside
a process.
children = [{SensorHub, device: "/dev/ttyACM0", speed: 460_800, channel: 15}]
Supervisor.start_link(children, strategy: :one_for_one)
SensorHub.open_joining(120) # then put a device into pairing mode
SensorHub.readings() #=> %{0xA1B2 => %{0x0402 => %{value: 21.4, unit: "°C", ...}}}
Persistence & restart
Almost none of the important state lives in your Elixir process. It lives in the dongle's flash (NVM3) and on the devices themselves:
| State | Lives in | Survives app restart | Survives dongle reboot |
|---|---|---|---|
| Network: PAN, channel, network key, TC link key | Dongle NVM3 | ✅ | ✅ |
| Joined devices + EUI64↔node-id table | Dongle NVM3 | ✅ | ✅ |
| APS link keys | Dongle NVM3 | ✅ | ✅ |
Endpoints (add_endpoint) | Host RAM (NCP doesn't persist them) | ⚠️ re-register each boot | ⚠️ re-register each boot |
| Bindings + reporting config | On the device (its own flash) | ✅ | ✅ |
Your app state (readings, device list) | Your process | ❌ rebuild it | ❌ rebuild it |
The consequence: after a crash or restart, do not call form_network/2 again.
Forming makes a new network (new key) and orphans every paired device. Instead,
re-establish the stored network:
# on start-up: rejoin the existing network, or form one only on first run
case Zigbee.reestablish_network(zb) do
{:ok, params} -> :reestablished # existing devices reconnect on their own
{:error, :no_network} -> Zigbee.form_network(zb) # first run: form a fresh network
end
# or the convenience wrapper:
Zigbee.reestablish_or_form_network(zb, channel: 15)
reestablish_network/2 re-registers the endpoints (host-side, not persisted) and calls
networkInit. Because bindings and reporting live on the devices, they keep
reporting to the coordinator with no re-pairing or re-interviewing, as long as
it comes back on the same network with the same endpoints. Your app-level state
(the readings map) is the only thing you rebuild; it repopulates as reports arrive
(the SensorHub example tracks devices as it hears from them, and a fuller hub can
read the NCP's child/address table to repopulate the list eagerly).
Caveat (unverified): on the ZBT-2's current firmware, a
networkInitafter aform_networkreturnedNOT_JOINEDin early testing, so the stored network may not have persisted. Thereestablish_networkpath is written to the spec but still needs confirming against real paired devices. Until then, treat restart and rejoin as designed but not yet proven.
Supported dongles
Any Silicon Labs EmberZNet dongle running Zigbee NCP (EZSP) firmware. Only the
ZBT-2 has been exercised end-to-end so far; the others speak the same EZSP protocol
and should work (pass the right :speed), but are untested here.
| Dongle | Radio | Status |
|---|---|---|
| Home Assistant Connect ZBT-2 | EFR32MG24 | ✅ Verified (EmberZNet 7.5.1.0, 460800 baud) |
| Home Assistant SkyConnect / ZBT-1 | EFR32MG21 | ⚙️ Should work (EZSP), untested |
| Sonoff ZBDongle-E | EFR32MG21 | ⚙️ Should work (EZSP), untested |
| SMLIGHT SLZB-06/07 | EFR32 | ⚙️ Should work (EZSP), untested |
| Home Assistant Yellow | EFR32MG24 | ⚙️ Should work (EZSP), untested |
| Sonoff ZBDongle-P, CC2652 sticks | TI CC2652 | ❌ Not currently supported (Z-Stack) |
| ConBee / RaspBee | n/a | ❌ Not currently supported (deCONZ) |
TI Z-Stack and deCONZ radios aren't supported yet: they speak a different NCP
protocol, so each needs its own Zigbee.Adapter backend (see
Writing a new backend). PRs welcome.
The ZBT-2 ships as a Zigbee coordinator. If yours has been flashed to Thread/Matter, reflash the Zigbee NCP image.
Architecture
The stack is split by the Zigbee.Adapter behaviour, so the chip-specific parts are
swappable and the application layer never sees a chip-specific frame:
Zigbee backend-agnostic facade (start_link/form/send_aps/…)
Zigbee.ZCL · Zigbee.ZDO pure Zigbee spec codecs, work with any backend
Zigbee.Interview join → interview → bind → report orchestration
Zigbee.Message normalized inbound APS message
───────────────────────── ▲ everything above depends only on the behaviour
Zigbee.Adapter the behaviour (contract) + %Zigbee.Adapter{} handle
───────────────────────── ▼ backends implement it
Zigbee.EZSP.Adapter Silicon Labs EmberZNet backend
├─ EZSP EmberZNet Serial Protocol (frame IDs, EmberStatus)
├─ ASH Asynchronous Serial Host framing
└─ Diagnostics dongle probing helpers
Zigbee.ZCL/Zigbee.ZDOare chip-agnostic codecs. The Zigbee specification defines these frames identically regardless of radio.Zigbee.EZSP.Adapterowns the dongle, runs the EmberZNet-specific coordinator sequence, and normalizes NCP callbacks into{:zigbee, _}events.
Writing a new backend
To support another radio family (e.g. TI Z-Stack), implement Zigbee.Adapter in a
new module (say Zigbee.ZNP.Adapter): own the serial link, implement the callbacks
(form_network, send_aps, and so on), and emit the same normalized events.
Interview, ZCL and ZDO don't change. This is the
zigpy model (a chip-agnostic core plus bellows,
zigpy-znp, and zigpy-deconz radio libraries).
See Zigbee.MockAdapter (in test/support) for a minimal, hardware-free reference
implementation.
Status
Codecs (ZCL, ZDO, EZSP.Frame, ASH) are unit-tested; Interview is tested
end-to-end against the in-memory Zigbee.MockAdapter (no hardware). The EZSP backend
(form / permit-join / add-endpoint) is verified live against a ZBT-2 on EmberZNet
7.5.1.0 through the facade. The Interview pairing→report flow still awaits live
Zigbee end-devices to validate the incoming-message layout and per-device quirks.
Testing
mix test