2 min read Gus Workman
Supervision trees on a circuit board
Hardware fails in ways software people rarely plan for. OTP already has an answer, and Nerves puts it on the board.
A sensor stops answering. A power rail sags for forty milliseconds. Someone unplugs the thing mid-write. On a server these are rare enough to page a human about. On a device sitting in a workshop for three years they are Tuesday.
The usual answer in C is a state machine that grows a new branch every time the field turns up a new failure. It works, and then a year later nobody can say what happens if the I2C bus wedges while the display is mid-refresh.
Let it crash, on hardware
We put each peripheral behind its own GenServer and let the supervisor decide what a failure means. A wedged sensor takes down one process, not the device.
defmodule Trellis.Sensors.Supervisor do
use Supervisor
def start_link(opts), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(_opts) do
children = [
{Trellis.Sensors.Moisture, bus: "i2c-1", address: 0x36},
{Trellis.Sensors.Temperature, bus: "i2c-1", address: 0x48}
]
Supervisor.init(children, strategy: :one_for_one, max_restarts: 10, max_seconds: 60)
end
end
:one_for_one is the interesting part. A moisture sensor that browns out gets
restarted on its own; the temperature sensor never notices. And
max_restarts: 10 means a sensor that has genuinely died — a cracked solder
joint, say — escalates instead of spinning forever, so the device can report a
fault rather than pretending everything is fine.
The read path
The process holds the bus reference and nothing else does. That removes a whole class of bug where two bits of code talk to the same address at once.
def handle_call(:read, _from, %{bus: bus, address: address} = state) do
case Circuits.I2C.write_read(bus, address, <<0x0F>>, 2) do
{:ok, <<raw::16>>} ->
{:reply, {:ok, raw / 65_535 * 100}, state}
{:error, reason} ->
# Crash rather than return stale data — the supervisor knows what to do
{:stop, {:i2c_error, reason}, state}
end
end
Returning {:error, reason} to the caller would be the polite thing to do. We
deliberately don’t. A sensor that fails a read has usually failed in a way that
a fresh Circuits.I2C.open/1 fixes, and crashing gets us that for free.
What it costs
Nerves boots slower than bare metal and the BEAM wants more RAM than an STM32 has. If a product needs to wake, sample, and sleep in under a second on a coin cell, this is the wrong tool and we reach for C on an M0.
The trade lands well when the device already needs a network stack, over-the-air updates, and something resembling an API. At that point you were going to build a scheduler and a fault-recovery story anyway. OTP has had one since 1998.