Skip to content
All posts

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.