How to Monitor Circuits.SPI with Vigilmon
Circuits.SPI puts Elixir in direct control of SPI peripherals — displays, ADCs, IMUs, flash chips, and dozens of other devices that ride the four-wire bus. On embedded Linux and Nerves targets, a silent SPI fault means a sensor stops updating, an ADC returns stale readings, or a display goes blank — and nothing in your logs tells you why.
External monitoring closes that gap. Vigilmon watches your device from the outside: if the firmware hangs, the SPI loop stops sending heartbeats, and you get an alert before the field team notices.
This tutorial covers:
- A GenServer health loop that validates SPI reads
- A lightweight HTTP health endpoint (Bandit + Plug, no Phoenix required)
- Vigilmon uptime + heartbeat monitoring
- Alerts for SPI timeouts and peripheral faults
Step 1: Add a SPI health GenServer
Create a supervisor-managed process that periodically exercises your SPI bus and tracks the last-known-good timestamp.
# lib/my_firmware/spi_health.ex
defmodule MyFirmware.SpiHealth do
use GenServer
require Logger
@bus "spidev0.0"
@interval_ms 30_000
defstruct last_ok: nil, consecutive_errors: 0, status: :unknown
def start_link(_opts), do: GenServer.start_link(__MODULE__, %__MODULE__{}, name: __MODULE__)
def status, do: GenServer.call(__MODULE__, :status)
@impl true
def init(state) do
schedule_check()
{:ok, state}
end
@impl true
def handle_info(:check, state) do
new_state = run_spi_check(state)
schedule_check()
{:noreply, new_state}
end
@impl true
def handle_call(:status, _from, state), do: {:reply, state, state}
defp run_spi_check(state) do
case Circuits.SPI.open(@bus) do
{:ok, ref} ->
# Send a no-op / WHOAMI byte and verify a non-zero response
result = Circuits.SPI.transfer(ref, <<0x75>>)
Circuits.SPI.close(ref)
case result do
{:ok, <<0x00>>} ->
Logger.warning("SPI: zero response — peripheral may be absent")
%{state | consecutive_errors: state.consecutive_errors + 1, status: :degraded}
{:ok, _response} ->
%{state | last_ok: System.monotonic_time(:second),
consecutive_errors: 0, status: :ok}
{:error, reason} ->
Logger.error("SPI transfer failed: #{inspect(reason)}")
%{state | consecutive_errors: state.consecutive_errors + 1, status: :error}
end
{:error, reason} ->
Logger.error("SPI open failed: #{inspect(reason)}")
%{state | consecutive_errors: state.consecutive_errors + 1, status: :error}
end
end
defp schedule_check, do: Process.send_after(self(), :check, @interval_ms)
end
Add it to your supervision tree:
# lib/my_firmware/application.ex
children = [
MyFirmware.SpiHealth,
{Bandit, plug: MyFirmware.HealthPlug, port: 4000}
]
Step 2: Expose a health endpoint
# lib/my_firmware/health_plug.ex
defmodule MyFirmware.HealthPlug do
@behaviour Plug
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
spi = MyFirmware.SpiHealth.status()
http_status = if spi.status == :ok, do: 200, else: 503
body = Jason.encode!(%{
status: to_string(spi.status),
spi_bus: "spidev0.0",
consecutive_errors: spi.consecutive_errors,
last_ok_seconds_ago: seconds_ago(spi.last_ok)
})
conn
|> put_resp_content_type("application/json")
|> send_resp(http_status, body)
|> halt()
end
def call(conn, _opts), do: conn
defp seconds_ago(nil), do: nil
defp seconds_ago(t), do: System.monotonic_time(:second) - t
end
Test it locally:
curl http://your-device-ip:4000/health
# {"status":"ok","spi_bus":"spidev0.0","consecutive_errors":0,"last_ok_seconds_ago":12}
Step 3: Set up Vigilmon HTTP monitoring
- Sign in at vigilmon.online and click Add Monitor.
- Choose HTTP monitor type.
- Enter
http://your-device-ip:4000/healthas the URL. - Set the check interval to 60 seconds (or 30 seconds for critical devices).
- Under Advanced, set expected status code to
200— Vigilmon will alert when the device returns503or is unreachable. - Click Save.
For devices behind a NAT or firewall, use a reverse proxy (nginx, Caddy) or a secure tunnel (Tailscale, Cloudflare Tunnel) to make the health endpoint reachable from Vigilmon's check nodes.
Step 4: Add heartbeat monitoring for the SPI loop
HTTP monitoring tells you the device is reachable. Heartbeat monitoring tells you the SPI loop is actually running. Wire the GenServer to ping Vigilmon on each successful check:
# in run_spi_check/1, after a successful transfer:
defp ping_vigilmon do
heartbeat_url = System.get_env("VIGILMON_HEARTBEAT_URL")
if heartbeat_url do
:httpc.request(:get, {String.to_charlist(heartbeat_url), []}, [], [])
end
end
Call ping_vigilmon() inside the {:ok, _response} branch of run_spi_check/1.
In Vigilmon:
- Open your monitor and click Heartbeat tab.
- Copy the unique heartbeat URL — set it as
VIGILMON_HEARTBEAT_URLin your firmware environment. - Set the grace period to 2 minutes (two missed 30-second checks before alert).
Step 5: Configure alerts
In Vigilmon, go to Alerts and add a notification channel:
- Email — instant delivery, good for on-call
- Slack —
#opschannel webhook - PagerDuty — for production embedded fleets
- SMS — critical hardware with no human nearby
Recommended alert policy for SPI hardware:
| Condition | Alert | |---|---| | HTTP 503 | Immediate — peripheral or bus fault | | Heartbeat missed ≥ 2× | Immediate — firmware hang or crash | | Response time > 5 s | Warning — bus contention or slow peripheral |
Step 6: Status page
Add your SPI device to a Vigilmon Status Page so field technicians can check device health without accessing the Vigilmon dashboard:
- Go to Status Pages → New Page.
- Add your SPI device monitor.
- Set the page to Public and share the URL with field support.
Why Monitor Circuits.SPI Externally?
The BEAM supervisor restarts crashed processes — but it cannot restart a bricked SPI bus or a frozen peripheral. Common failure modes that require external eyes:
- Peripheral lock-up — the device stops responding but the OS never raises an error
- Bit-bang timing drift — high CPU load corrupts SPI timing; reads silently return garbage
- Power brown-out — peripheral loses power, BEAM process keeps running
- SD card or flash saturation — the process is alive but the filesystem behind it is read-only
Vigilmon's heartbeat check catches all of these: when the SPI health loop can no longer complete a transfer, it stops pinging, and you get alerted within minutes.
Key Metrics to Watch
| Metric | Threshold | Meaning |
|---|---|---|
| HTTP status | Must be 200 | SPI bus functional |
| consecutive_errors | > 3 | Persistent peripheral fault |
| last_ok_seconds_ago | > 120 | Loop stalled |
| Heartbeat interval | 2× missed | Firmware hang |
| Response latency | > 2 s | Bus contention |
Conclusion
Circuits.SPI gives you direct, idiomatic Elixir access to the SPI bus. Vigilmon gives you external eyes on whether that bus is actually working in the field. Together — a health GenServer that exercises the bus, a Plug endpoint that reports status, and heartbeat pings on each successful transfer — you have production-grade observability for embedded Elixir hardware.
Get started free at vigilmon.online.