tutorial

How to Monitor Circuits.I2C with Vigilmon

Track I2C bus health, peripheral availability, read/write latency, and sensor data quality on Nerves and embedded Linux devices, and use Vigilmon to alert when I2C peripherals go offline or return bad data.

How to Monitor Circuits.I2C with Vigilmon

Circuits.I2C is the Elixir library for I2C (Inter-Integrated Circuit) bus communication on embedded Linux systems and Nerves devices. It provides a clean, supervised interface for reading from and writing to I2C peripherals: temperature sensors, humidity sensors, barometric pressure sensors, OLED displays, ADC chips, real-time clocks, and hundreds of other devices that use the two-wire I2C protocol.

I2C devices fail in ways that are easy to miss at the application level. A temperature sensor that stops responding returns a bus error, but if you catch and silently discard that error your application keeps running with stale data. A sensor that reports 0°C or -128°C because of a CRC failure looks like a valid reading without range validation. An I2C address collision or bus lockup can make an entire bus unresponsive, taking out multiple sensors at once without any single-sensor alert.

Vigilmon adds the external observability layer your embedded system needs: detect bus errors, track read latency, and alert when sensors go silent or return invalid data.


Why Monitor I2C in Embedded Systems?

I2C failures are subtle and dangerous for physical systems:

  • Bus lockup — a peripheral that holds SDA low after a power glitch can lock the entire I2C bus; all devices on the bus become unresponsive until the bus is reset
  • Silent stale data — when reads fail, applications that cache the last good value continue operating on stale sensor data without any visible error
  • Address collision — two devices with the same I2C address (common when mixing modules from different vendors) cause read failures that look like individual device faults
  • CRC/checksum failures — many I2C sensors include CRC bytes that catch corrupted reads; applications that skip CRC validation accept bad data silently
  • Peripheral dropout under load — sensors that work at room temperature may fail when the enclosure heats up; thermal stress causes I2C communication errors before complete failure
  • Clock stretching timeout — slow peripherals stretch the SCL clock; if the Linux I2C driver timeout is set too low, valid devices appear unresponsive

Key Metrics to Track

| Metric | What it tells you | |--------|-------------------| | Read success rate by device address | Whether each I2C peripheral is responding reliably | | Read/write latency by device | Whether communication is fast or degrading | | Bus error count by type | Distinguish NACK, timeout, and CRC errors | | Sensor value range violations | Whether returned values are physically plausible | | Time since last successful read per device | Detect silent dropout when errors are suppressed | | Bus scan result (on startup) | Whether expected devices are present at their configured addresses |


Step 1: Add Circuits.I2C to Your Project

# mix.exs
defp deps do
  [
    {:circuits_i2c, "~> 2.0"}
  ]
end

Basic I2C read and write:

# lib/my_app/i2c_client.ex
defmodule MyApp.I2cClient do
  @moduledoc """
  Low-level I2C read/write helpers with instrumentation.
  """

  require Logger

  @doc "Read `byte_count` bytes from `device_address` on the given bus."
  def read(bus_ref, device_address, byte_count) do
    start = System.monotonic_time()

    case Circuits.I2C.read(bus_ref, device_address, byte_count) do
      {:ok, data} ->
        duration = System.monotonic_time() - start

        :telemetry.execute(
          [:my_app, :i2c, :read],
          %{duration: duration, count: 1},
          %{address: format_address(device_address), bytes: byte_count, success: true}
        )

        {:ok, data}

      {:error, reason} ->
        duration = System.monotonic_time() - start

        :telemetry.execute(
          [:my_app, :i2c, :read],
          %{duration: duration, count: 1},
          %{address: format_address(device_address), bytes: byte_count, success: false}
        )

        :telemetry.execute(
          [:my_app, :i2c, :error],
          %{count: 1},
          %{address: format_address(device_address), operation: :read, reason: inspect(reason)}
        )

        Logger.warning("I2C read failed",
          address: format_address(device_address),
          reason: inspect(reason)
        )

        {:error, reason}
    end
  end

  @doc "Write `data` bytes to `device_address` on the given bus."
  def write(bus_ref, device_address, data) do
    start = System.monotonic_time()

    case Circuits.I2C.write(bus_ref, device_address, data) do
      :ok ->
        duration = System.monotonic_time() - start

        :telemetry.execute(
          [:my_app, :i2c, :write],
          %{duration: duration, count: 1},
          %{address: format_address(device_address), bytes: byte_size(data), success: true}
        )

        :ok

      {:error, reason} ->
        :telemetry.execute(
          [:my_app, :i2c, :error],
          %{count: 1},
          %{address: format_address(device_address), operation: :write, reason: inspect(reason)}
        )

        Logger.warning("I2C write failed",
          address: format_address(device_address),
          reason: inspect(reason)
        )

        {:error, reason}
    end
  end

  @doc "Write then read in a single transaction."
  def write_read(bus_ref, device_address, write_data, read_bytes) do
    start = System.monotonic_time()

    case Circuits.I2C.write_read(bus_ref, device_address, write_data, read_bytes) do
      {:ok, data} ->
        duration = System.monotonic_time() - start

        :telemetry.execute(
          [:my_app, :i2c, :write_read],
          %{duration: duration, count: 1},
          %{address: format_address(device_address), success: true}
        )

        {:ok, data}

      {:error, reason} ->
        :telemetry.execute(
          [:my_app, :i2c, :error],
          %{count: 1},
          %{address: format_address(device_address), operation: :write_read, reason: inspect(reason)}
        )

        {:error, reason}
    end
  end

  defp format_address(addr), do: "0x#{Integer.to_string(addr, 16)}"
end

Step 2: Sensor Driver with Validation

Example: BME280 temperature/humidity/pressure sensor with range validation:

# lib/my_app/sensors/bme280.ex
defmodule MyApp.Sensors.BME280 do
  @moduledoc """
  Driver for the BME280 temperature, humidity, and pressure sensor.
  Includes range validation and instrumented reads.
  """

  require Logger

  @i2c_address 0x76
  @bus_name "i2c-1"

  # Valid physical ranges for BME280
  @temp_range_celsius -40..85
  @humidity_range_pct 0..100
  @pressure_range_hpa 300..1100

  def start(opts \\ []) do
    bus_name = Keyword.get(opts, :bus_name, @bus_name)
    {:ok, bus} = Circuits.I2C.open(bus_name)
    {:ok, %{bus: bus, address: @i2c_address, last_reading: nil}}
  end

  def read_all(%{bus: bus, address: addr} = state) do
    with {:ok, raw} <- MyApp.I2cClient.write_read(bus, addr, <<0xF7>>, 8),
         {:ok, reading} <- parse_and_validate(raw) do

      :telemetry.execute(
        [:my_app, :i2c, :sensor_reading],
        %{
          temperature: reading.temperature_c,
          humidity: reading.humidity_pct,
          pressure: reading.pressure_hpa
        },
        %{sensor: "bme280", address: "0x#{Integer.to_string(addr, 16)}"}
      )

      {:ok, %{state | last_reading: reading}}
    else
      {:error, :invalid_range, field, value} ->
        :telemetry.execute(
          [:my_app, :i2c, :sensor_validation_error],
          %{count: 1},
          %{sensor: "bme280", field: field, value: value}
        )

        Logger.error("BME280 reading out of valid range",
          field: field,
          value: value
        )

        {:error, {:invalid_range, field, value}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp parse_and_validate(<<p_msb, p_lsb, p_xlsb, t_msb, t_lsb, t_xlsb, h_msb, h_lsb>>) do
    # Raw conversion (simplified — real BME280 needs calibration coefficients)
    temp_raw = (t_msb <<< 12) ||| (t_lsb <<< 4) ||| (t_xlsb >>> 4)
    humid_raw = (h_msb <<< 8) ||| h_lsb
    press_raw = (p_msb <<< 12) ||| (p_lsb <<< 4) ||| (p_xlsb >>> 4)

    temperature_c = temp_raw / 5120.0
    humidity_pct = humid_raw / 1024.0
    pressure_hpa = press_raw / 25600.0

    cond do
      round(temperature_c) not in @temp_range_celsius ->
        {:error, :invalid_range, :temperature_c, temperature_c}

      round(humidity_pct) not in @humidity_range_pct ->
        {:error, :invalid_range, :humidity_pct, humidity_pct}

      round(pressure_hpa) not in @pressure_range_hpa ->
        {:error, :invalid_range, :pressure_hpa, pressure_hpa}

      true ->
        {:ok, %{temperature_c: temperature_c, humidity_pct: humidity_pct, pressure_hpa: pressure_hpa}}
    end
  end
end

Step 3: Bus Scan on Startup

Verify expected I2C devices are present when the system starts:

# lib/my_app/i2c_bus_checker.ex
defmodule MyApp.I2cBusChecker do
  require Logger

  @expected_devices %{
    0x76 => "BME280 (temp/humidity/pressure)",
    0x3C => "SSD1306 (OLED display)",
    0x48 => "ADS1115 (ADC)"
  }

  @bus_name "i2c-1"

  def scan_and_verify do
    {:ok, bus} = Circuits.I2C.open(@bus_name)
    found_addresses = Circuits.I2C.detect_devices(bus)
    Circuits.I2C.close(bus)

    found_set = MapSet.new(found_addresses)

    results =
      Map.new(@expected_devices, fn {addr, description} ->
        present = MapSet.member?(found_set, addr)

        :telemetry.execute(
          [:my_app, :i2c, :device_present],
          %{present: if(present, do: 1, else: 0)},
          %{address: "0x#{Integer.to_string(addr, 16)}", description: description}
        )

        unless present do
          Logger.error("Expected I2C device not found",
            address: "0x#{Integer.to_string(addr, 16)}",
            description: description
          )
        end

        {"0x#{Integer.to_string(addr, 16)}", %{description: description, present: present}}
      end)

    all_present = Enum.all?(results, fn {_, v} -> v.present end)
    {if(all_present, do: :ok, else: :error), results}
  end
end

Step 4: Define Telemetry Metrics

# lib/my_app/telemetry.ex
def metrics do
  [
    # Read operations
    counter("my_app.i2c.read.count",
      tags: [:address, :bytes, :success]
    ),
    distribution("my_app.i2c.read.duration",
      unit: {:native, :microsecond},
      reporter_options: [buckets: [100, 500, 2000, 10_000, 50_000]],
      tags: [:address]
    ),

    # Write operations
    counter("my_app.i2c.write.count",
      tags: [:address, :success]
    ),

    # Write-read operations
    counter("my_app.i2c.write_read.count",
      tags: [:address, :success]
    ),
    distribution("my_app.i2c.write_read.duration",
      unit: {:native, :microsecond},
      reporter_options: [buckets: [100, 500, 2000, 10_000, 50_000]],
      tags: [:address]
    ),

    # Errors
    counter("my_app.i2c.error.count",
      tags: [:address, :operation, :reason]
    ),

    # Sensor readings
    last_value("my_app.i2c.sensor_reading.temperature",
      tags: [:sensor, :address]
    ),
    last_value("my_app.i2c.sensor_reading.humidity",
      tags: [:sensor, :address]
    ),
    last_value("my_app.i2c.sensor_reading.pressure",
      tags: [:sensor, :address]
    ),

    # Validation errors
    counter("my_app.i2c.sensor_validation_error.count",
      tags: [:sensor, :field]
    ),

    # Device presence
    last_value("my_app.i2c.device_present.present",
      tags: [:address, :description]
    )
  ]
end

Step 5: Health Endpoint

# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    {bus_status, devices} = MyApp.I2cBusChecker.scan_and_verify()

    checks = %{
      i2c_bus: %{
        status: if(bus_status == :ok, do: "ok", else: "degraded"),
        devices: devices
      }
    }

    status = if bus_status == :ok, do: 200, else: 503

    conn
    |> put_status(status)
    |> json(%{status: if(status == 200, do: "ok", else: "degraded"), checks: checks})
  end
end

Step 6: Heartbeat Monitor for Sensor Liveness

# lib/my_app/i2c_heartbeat.ex
defmodule MyApp.I2cHeartbeat do
  use GenServer
  require Logger

  @heartbeat_url System.get_env("VIGILMON_I2C_HEARTBEAT_URL")
  @interval :timer.minutes(5)

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def init(state) do
    schedule()
    {:ok, state}
  end

  def handle_info(:ping, state) do
    {status, _devices} = MyApp.I2cBusChecker.scan_and_verify()

    case status do
      :ok ->
        ping_vigilmon()
        Logger.debug("I2C health check passed — heartbeat sent")

      :error ->
        Logger.error("I2C bus check failed — not pinging Vigilmon heartbeat")
    end

    schedule()
    {:noreply, state}
  end

  defp ping_vigilmon do
    if @heartbeat_url do
      :httpc.request(:get, {String.to_charlist(@heartbeat_url), []}, [{:timeout, 5_000}], [])
    end
  end

  defp schedule, do: Process.send_after(self(), :ping, @interval)
end

Add to your supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.I2cHeartbeat,
  MyAppWeb.Endpoint
]

Step 7: Set Up Monitors in Vigilmon

HTTP monitor for health endpoint:

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. URL: http://your-device/health
  4. Interval: 60 seconds
  5. Alert condition: status 200 and body contains "i2c_bus":{"status":"ok"}

Heartbeat monitor for sensor liveness:

  1. Click New Monitor → Heartbeat
  2. Set expected interval to 8 minutes
  3. Copy the ping URL and set it as VIGILMON_I2C_HEARTBEAT_URL in your firmware
  4. Enable PagerDuty alerting — a missed I2C heartbeat from a production sensor device means data has stopped flowing

Step 8: Alerting

In Vigilmon → Notifications:

Slack alert for sensor failure:

🚨 I2C Bus Degraded — sensor-unit-007
Failed devices: 0x76 (BME280), 0x48 (ADS1115)
Action: Check I2C bus connections and power supply to peripheral rail

Alert for out-of-range readings:

defmodule MyApp.SensorAlertHandler do
  use GenServer

  def init(state) do
    :telemetry.attach(
      "sensor-alert-handler",
      [:my_app, :i2c, :sensor_validation_error],
      &handle_event/4,
      nil
    )
    {:ok, state}
  end

  def handle_event(_event, %{count: 1}, %{sensor: sensor, field: field, value: value}, _) do
    require Logger
    Logger.error("Sensor returned out-of-range value",
      sensor: sensor,
      field: field,
      value: value
    )
  end
end

Common I2C Issues and Fixes

Bus lockup recovery:

# Attempt a bus reset by closing and reopening the bus reference
defmodule MyApp.I2cBusManager do
  use GenServer

  def reset_bus(bus_name \\ "i2c-1") do
    # Close existing bus handle
    case GenServer.call(__MODULE__, :get_bus) do
      {:ok, bus} -> Circuits.I2C.close(bus)
      _ -> :ok
    end

    # Reopen — Linux I2C driver reinitializes on new open
    case Circuits.I2C.open(bus_name) do
      {:ok, new_bus} ->
        GenServer.call(__MODULE__, {:set_bus, new_bus})
        {:ok, new_bus}

      {:error, reason} ->
        {:error, reason}
    end
  end
end

CRC validation:

def validate_crc(<<data::binary-size(n), crc_byte>>, n) do
  computed = crc8(data)
  if computed == crc_byte, do: {:ok, data}, else: {:error, :crc_mismatch}
end

defp crc8(data) do
  Enum.reduce(:binary.bin_to_list(data), 0xFF, fn byte, acc ->
    xored = bxor(acc, byte)
    Enum.reduce(0..7, xored, fn _, a ->
      if (a &&& 0x80) != 0, do: bxor(a <<< 1, 0x31) &&& 0xFF, else: (a <<< 1) &&& 0xFF
    end)
  end)
end

What You've Built

| What | How | |------|-----| | Instrumented I2C reads and writes | Telemetry on every bus operation with success/latency | | Sensor value range validation | Reject physically implausible readings before they reach application logic | | Bus device scan | Startup check verifying all expected peripherals are present | | Health endpoint | JSON response with per-device I2C bus status | | Liveness heartbeat | GenServer pinging Vigilmon only when all devices pass health check | | Out-of-range alerting | Telemetry handler logging and alerting on invalid sensor readings |

Circuits.I2C connects your Elixir application to the physical world through sensors and peripherals. Vigilmon ensures that data keeps flowing — alerting you when a sensor drops off the bus, a CRC failure indicates corrupted reads, or a device returns values outside safe physical ranges.


Next Steps

  • Combine with Circuits.GPIO to correlate sensor readings with digital I/O events (e.g., temperature rising as a motor GPIO goes high)
  • Add historical sensor data storage and trend monitoring to catch slow drift before out-of-range thresholds trigger
  • Use Vigilmon's heartbeat monitor for each device class (environmental sensors, displays, ADCs) as separate monitors for faster incident triage
  • For fleets of sensor devices, group Vigilmon heartbeat monitors into a fleet status page

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 →