Skip to main content

README.md

# 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, …).

```elixir
{: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:

```elixir
# 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:

```elixir
{: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:

```elixir
{: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`).

```elixir
# 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:

```elixir
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`:

```elixir
# 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:

```elixir
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`](examples/pair_and_read.exs) is a complete, runnable
demo: form a network, pair one device, and print its readings live.

```sh
# 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`](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.

```elixir
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:

```elixir
# 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 `networkInit` after a
> `form_network` returned `NOT_JOINED` in early testing, so the stored network may
> not have persisted. The `reestablish_network` path 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](#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.ZDO` are chip-agnostic codecs. The Zigbee
  specification defines these frames identically regardless of radio.
- `Zigbee.EZSP.Adapter` owns 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](https://github.com/zigpy/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

```sh
mix test
```