tutorial

How to Monitor Circuits.SPI with Vigilmon

Add uptime monitoring, heartbeat checks, and alerts to your Elixir embedded Linux projects using Circuits.SPI — detect SPI bus failures, peripheral faults, and firmware hangs before they silently corrupt data.

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

  1. Sign in at vigilmon.online and click Add Monitor.
  2. Choose HTTP monitor type.
  3. Enter http://your-device-ip:4000/health as the URL.
  4. Set the check interval to 60 seconds (or 30 seconds for critical devices).
  5. Under Advanced, set expected status code to 200 — Vigilmon will alert when the device returns 503 or is unreachable.
  6. 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:

  1. Open your monitor and click Heartbeat tab.
  2. Copy the unique heartbeat URL — set it as VIGILMON_HEARTBEAT_URL in your firmware environment.
  3. 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#ops channel 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:

  1. Go to Status PagesNew Page.
  2. Add your SPI device monitor.
  3. 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →