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:
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- URL:
http://your-device/health - Interval: 60 seconds
- Alert condition: status
200and body contains"i2c_bus":{"status":"ok"}
Heartbeat monitor for sensor liveness:
- Click New Monitor → Heartbeat
- Set expected interval to 8 minutes
- Copy the ping URL and set it as
VIGILMON_I2C_HEARTBEAT_URLin your firmware - 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.