Current section
Files
Jump to
Current section
Files
lib/zigbee.ex
defmodule Zigbee do
@moduledoc """
A from-scratch, pure-Elixir Zigbee stack.
This module is the backend-agnostic public API. You open a chip backend (which
implements `Zigbee.Adapter`) and then drive it through these functions. The
same calls work no matter which radio is underneath.
## Quick start
Open the radio, form a coordinator network, then pair and read a sensor:
# 1. open the dongle (Silicon Labs EmberZNet backend) and become its 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 the default HA endpoint)
{:ok, params} = Zigbee.form_network(zb, channel: 15)
# 3. open joining and wait for a device (put the device into pairing mode now)
{:ok, dev} = Zigbee.Interview.open_and_wait(zb)
# 4. interview it: enumerate endpoints, bind + configure reporting for temp/humidity
{:ok, _report} = Zigbee.Interview.run(zb, dev.node_id, dev.eui64)
# 5. watch the readings roll in (°C / %)
Zigbee.Interview.collect(zb, 60_000)
#=> [%{cluster: 0x0402, endpoint: 1, value: 21.4, unit: "°C"}, ...]
## Events
The subscriber (set with `subscribe/2`) receives backend-neutral events:
{:zigbee, :device_joined, %{node_id: _, eui64: _}}
{:zigbee, :message, %Zigbee.Message{}}
`Zigbee.Interview` consumes these for you; consume them yourself only for custom
flows. See `Zigbee.Interview` for pairing, `Zigbee.ZCL` / `Zigbee.ZDO` for the
wire codecs, and `Zigbee.Adapter` for writing a new radio backend.
"""
alias Zigbee.Adapter
@doc """
Start a radio `backend` (a module implementing `Zigbee.Adapter`, e.g.
`Zigbee.EZSP.Adapter`) and wrap it in a handle used by every other function here.
`opts` are passed to the backend. For `Zigbee.EZSP.Adapter`: `:device` (serial
port) and `:speed` (baud, 460_800 for the ZBT-2).
"""
@spec start_link(module(), keyword()) :: {:ok, Adapter.t()} | {:error, term()}
def start_link(backend, opts \\ []) when is_atom(backend) do
with {:ok, ref} <- backend.start_link(opts) do
{:ok, %Adapter{module: backend, ref: ref}}
end
end
@doc "Wrap an already-started backend process in a handle."
@spec wrap(module(), Adapter.ref()) :: Adapter.t()
def wrap(backend, ref), do: %Adapter{module: backend, ref: ref}
@doc "Radio/stack info (e.g. `%{protocol_version: 13, stack_version: \"7.5.1.0\"}`)."
def info(%Adapter{module: m, ref: r}), do: m.info(r)
@doc """
Register `pid` (default: the caller) to receive the normalized `{:zigbee, _}`
events. Do this before `Zigbee.Interview` calls so join/message events reach it.
"""
def subscribe(%Adapter{module: m, ref: r}, pid \\ self()), do: m.subscribe(r, pid)
@doc """
Form a centralized (trust-center) coordinator network and return its parameters.
Options (all optional): `:channel` (11..26, default 15), `:pan_id`,
`:extended_pan_id` (8 bytes), `:tx_power` (dBm), `:network_key` (16 bytes), and
`:endpoints` (`:default` registers HA endpoint 1, `:none`, or a list of
`{endpoint, profile, device_id, in_clusters, out_clusters}`). Endpoints are
registered here because they must exist before the network comes up.
"""
def form_network(%Adapter{module: m, ref: r}, opts \\ []), do: m.form_network(r, opts)
@doc """
Re-establish the network already stored on the radio (via networkInit),
re-registering endpoints first. Returns `{:ok, params}`, or `{:error, :no_network}`
if nothing is stored. Use this on restart (not `form_network/2`): forming makes a
*new* network (new key) and orphans already-paired devices.
"""
def reestablish_network(%Adapter{module: m, ref: r}, opts \\ []),
do: m.reestablish_network(r, opts)
@doc """
Re-establish the stored network, or form a fresh one if there is none. The right
bring-up for a long-running coordinator: rejoins existing devices when possible,
forms only on first run. See "Persistence & restart" in the README.
"""
def reestablish_or_form_network(%Adapter{} = handle, opts \\ []) do
case reestablish_network(handle, opts) do
{:error, :no_network} -> form_network(handle, opts)
other -> other
end
end
@doc "Open the network for joining for `seconds` (0..254; 255 = no timeout)."
def permit_joining(%Adapter{module: m, ref: r}, seconds \\ 180),
do: m.permit_joining(r, seconds)
@doc """
Register an application endpoint. Rarely needed directly; `form_network/2`
registers the default endpoint. Must be called before the network is up.
"""
def add_endpoint(
%Adapter{module: m, ref: r},
endpoint,
profile,
device_id,
in_clusters,
out_clusters
),
do: m.add_endpoint(r, endpoint, profile, device_id, in_clusters, out_clusters)
@doc """
Send a direct APS unicast to `node_id` and return `{:ok, aps_seq}`. `payload` is
a raw APS payload. Build it with `Zigbee.ZCL` (on an application profile like
`0x0104`) or `Zigbee.ZDO` (on profile `0x0000`). `opts`: `:src_endpoint`
(default 1). `Zigbee.Interview` uses this under the hood.
"""
def send_aps(
%Adapter{module: m, ref: r},
node_id,
profile,
cluster,
dst_endpoint,
payload,
opts \\ []
),
do: m.send_aps(r, node_id, profile, cluster, dst_endpoint, payload, opts)
@doc """
The coordinator's own identifier: its 64-bit IEEE 802.15.4 extended address
(EUI64), the radio's permanent, globally-unique hardware address, like a MAC
address. Returned as a raw 8-byte little-endian binary (wire order).
Unlike a node's 16-bit network address (the coordinator's is always `0x0000`),
the identifier never changes. Devices bind their clusters to it, so it's the
stable identity that lets `reestablish_network/2` bring paired devices back after
a restart with no re-pairing.
"""
def identifier(%Adapter{module: m, ref: r}), do: m.identifier(r)
@doc """
Reset the coordinator: leave / tear down the current network, clearing the
network state stored on the radio. The next bring-up must `form_network/2`.
"""
def reset_network(%Adapter{module: m, ref: r}), do: m.reset_network(r)
end