Current section
Files
Jump to
Current section
Files
examples/sensor_hub.exs
defmodule SensorHub do
@moduledoc """
Example: driving the `zigbee` library from a supervised GenServer.
A long-running "sensor hub" that owns the radio, opens joining on demand, and
keeps the latest temperature/humidity reading for every device that joins.
## What this shows
* hold the `%Zigbee.Adapter{}` handle in the GenServer's state
* make the GenServer the event **subscriber**, so `{:zigbee, _}` events land in
`handle_info/2`
* do slow start-up work in `handle_continue/2`, not `init/1`
* **re-establish** the network stored on the dongle across restarts, rather than
re-forming, which would make a new network and orphan already-paired devices
* handle joins and reports **reactively**
## Important
Do **not** call the blocking `Zigbee.Interview.*` helpers from inside a
GenServer, they run their own `receive` loop and would swallow the process's
messages. Instead, react to `{:zigbee, :device_joined, _}` by firing the
bind + configure-reporting requests with `Zigbee.send_aps/7` (fire-and-forget),
and let the resulting `{:zigbee, :message, _}` reports flow into `handle_info/2`.
(If you need the synchronous `Interview` flow, run it in its own task/process
that is the subscriber, one process owns the event stream at a time.)
## Usage
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", at: ~U[...]}, ...}}
"""
use GenServer
require Logger
alias Zigbee.{ZCL, ZDO, Message}
@temperature 0x0402
@humidity 0x0405
@report_clusters [@temperature, @humidity]
@ha_profile 0x0104
@zdo_profile 0x0000
# ── Client API ─────────────────────────────────────────────────────────────
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "Open the join window for `seconds`; then put a device into pairing mode."
def open_joining(server \\ __MODULE__, seconds \\ 180),
do: GenServer.call(server, {:open_joining, seconds})
@doc "Latest readings, as `%{node_id => %{cluster => %{value, unit, at}}}`."
def readings(server \\ __MODULE__), do: GenServer.call(server, :readings)
@doc "Devices seen, as `%{node_id => %{eui64: _}}`."
def devices(server \\ __MODULE__), do: GenServer.call(server, :devices)
# ── Server ─────────────────────────────────────────────────────────────────
@impl true
def init(opts) do
{:ok, zb} =
Zigbee.start_link(Zigbee.EZSP.Adapter,
device: Keyword.fetch!(opts, :device),
speed: Keyword.get(opts, :speed, 460_800)
)
# This GenServer is the event subscriber → events arrive in handle_info/2.
:ok = Zigbee.subscribe(zb)
state = %{
zb: zb,
channel: Keyword.get(opts, :channel, 15),
coord_eui64: nil,
devices: %{},
readings: %{},
seq: 0
}
# Bringing the network up blocks until NETWORK_UP, do it off the init path.
{:ok, state, {:continue, :bring_up}}
end
@impl true
def handle_continue(:bring_up, s) do
# Re-establish the network already stored on the dongle; only form a NEW one if
# there is none. Forming on every restart would make a new network (new key) and
# orphan already-paired devices. Paired devices reconnect on their own once the
# network is re-established, their bindings + reporting config live on the
# device, not here.
{origin, params} =
case Zigbee.reestablish_network(s.zb) do
{:ok, p} ->
{"re-established", p}
{:error, :no_network} ->
{:ok, p} = Zigbee.form_network(s.zb, channel: s.channel)
{"formed", p}
end
{:ok, coord_eui64} = Zigbee.identifier(s.zb)
Logger.info("SensorHub: #{origin} network on channel #{params.channel}")
{:noreply, %{s | coord_eui64: coord_eui64}}
end
@impl true
def handle_call({:open_joining, seconds}, _from, s),
do: {:reply, Zigbee.permit_joining(s.zb, seconds), s}
def handle_call(:readings, _from, s), do: {:reply, s.readings, s}
def handle_call(:devices, _from, s), do: {:reply, s.devices, s}
@impl true
# A device joined, remember it and set up temperature/humidity reporting.
def handle_info({:zigbee, :device_joined, %{node_id: node, eui64: eui}}, s) do
Logger.info("SensorHub: device 0x#{Integer.to_string(node, 16)} joined")
s = %{s | devices: Map.put(s.devices, node, %{eui64: eui})}
{:noreply, configure_sensor(s, node, eui)}
end
# A report arrived, decode it and store the latest reading.
def handle_info(
{:zigbee, :message, %Message{profile: @ha_profile, source: node, cluster: cluster, payload: payload}},
s
)
when cluster in @report_clusters do
{:noreply, store_report(s, node, cluster, payload)}
end
# ZDO acks and anything else we don't care about.
def handle_info({:zigbee, :message, _msg}, s), do: {:noreply, s}
def handle_info(_other, s), do: {:noreply, s}
# ── Internals ──────────────────────────────────────────────────────────────
# Fire-and-forget bind + configure-reporting for each report cluster. Assumes
# the sensor is on endpoint 1, a fuller hub would ZDO-interview the endpoints
# first (see Zigbee.Interview for that logic).
defp configure_sensor(s, node, eui) do
Enum.reduce(@report_clusters, s, fn cluster, s ->
# bind: make the device deliver this cluster to us (ZDO, profile 0x0000)
{bind_seq, s} = next(s)
bind = ZDO.bind_request(bind_seq, eui, 1, cluster, s.coord_eui64, 1)
_ = Zigbee.send_aps(s.zb, node, @zdo_profile, ZDO.bind_cluster(), 0, bind, src_endpoint: 0)
# configure reporting: temperature is int16 (0x29), humidity uint16 (0x21)
{cfg_seq, s} = next(s)
type = if cluster == @temperature, do: 0x29, else: 0x21
rec = %{attr_id: 0x0000, type: type, min: 30, max: 300, change: 10}
_ = Zigbee.send_aps(s.zb, node, @ha_profile, cluster, 1, ZCL.configure_reporting(cfg_seq, [rec]))
s
end)
end
defp store_report(s, node, cluster, payload) do
# After re-establishing, devices reconnect without a fresh join event, track any
# node we hear a report from so `devices/0` reflects it. (A fuller hub could instead
# read the NCP's child/address table on restart to repopulate the list eagerly.)
s = %{s | devices: Map.put_new(s.devices, node, %{})}
case ZCL.decode(payload) do
{:ok, %{command_name: :report_attributes, payload: reports}} ->
Enum.reduce(reports, s, fn %{value: raw}, s ->
{value, unit} =
case cluster do
@temperature -> {ZCL.temperature_c(raw), "°C"}
@humidity -> {ZCL.humidity_pct(raw), "%"}
end
Logger.info("SensorHub: 0x#{Integer.to_string(node, 16)} #{value}#{unit}")
reading = %{value: value, unit: unit, at: DateTime.utc_now()}
per_node = s.readings |> Map.get(node, %{}) |> Map.put(cluster, reading)
%{s | readings: Map.put(s.readings, node, per_node)}
end)
_ ->
s
end
end
# A tiny transaction-sequence source (wraps at 255) held in state.
defp next(s), do: {rem(s.seq, 256), %{s | seq: s.seq + 1}}
end
# Loading this file just defines the module. To try it live:
#
# {:ok, _pid} = SensorHub.start_link(device: "/dev/ttyACM0", speed: 460_800, channel: 15)
# SensorHub.open_joining(120) # put a device into pairing mode
# Process.sleep(60_000)
# SensorHub.readings()