# SPDX-FileCopyrightText: 2026 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.Sensor.INA219 do @moduledoc """ A BB sensor that polls an INA219 voltage / current / power monitor over I2C and publishes `BB.Message.Sensor.PowerState` messages. The INA219 is a general-purpose power monitor. Use it to watch a battery, a motor's draw for stall detection, a logic rail, a solar input, or any other electrical bus you can run a shunt resistor on. ## Example DSL Usage topology do link :chassis do sensor :main_bus, {BB.Sensor.INA219, bus: "i2c-1", address: 0x40, calibration: :calibrate_32V_2A, publish_rate: ~u(10 hertz) } end end ## Options - `bus` — I2C bus name (e.g. `"i2c-1"`) — required. - `address` — I2C address (default `0x40`). - `calibration` — One of `:calibrate_32V_2A` (default), `:calibrate_32V_1A`, `:calibrate_16V_400mA`. These match the helper functions on the underlying `INA219` library and assume a 0.1Ω shunt resistor (Adafruit breakout). Each preset implies a fixed `current_divisor` and `power_divisor`. - `publish_rate` — How often to read + publish (default `~u(1 hertz)`). ## Published Messages `BB.Message.Sensor.PowerState` published to `[:sensor | path]` where `path` is the sensor's position in the topology. Fields are in SI units (Volts, Amperes, Watts). A single failed read is logged at warning level and does not crash the process — the polling loop continues. Persistent failures will manifest as silence on the topic rather than a crash; future work may add a heartbeat or self-test message for liveness monitoring. """ use BB.Sensor import BB.Unit import BB.Unit.Option alias BB.Message alias BB.Message.Sensor.PowerState alias BB.Robot.Units alias Localize.Unit alias Wafer.Driver.Circuits.I2C, as: CircuitsI2C require Logger @calibrations [:calibrate_32V_2A, :calibrate_32V_1A, :calibrate_16V_400mA] @impl BB.Sensor def options_schema do Spark.Options.new!( bus: [ type: :string, required: true, doc: "I2C bus name (e.g. \"i2c-1\")" ], address: [ type: :integer, default: 0x40, doc: "I2C address of the INA219" ], calibration: [ type: {:in, @calibrations}, default: :calibrate_32V_2A, doc: "Calibration preset (matches INA219.calibrate_*/1 helpers)" ], publish_rate: [ type: unit_type(compatible: :hertz), default: ~u(1 hertz), doc: "Rate at which to read the sensor and publish PowerState" ] ) end @impl BB.Sensor def init(opts) do opts = Map.new(opts) {current_divisor, power_divisor} = divisors_for(opts.calibration) with {:ok, conn} <- CircuitsI2C.acquire(bus_name: opts.bus, address: opts.address), {:ok, ina} <- INA219.acquire( conn: conn, current_divisor: current_divisor, power_divisor: power_divisor ), {:ok, ina} <- apply(INA219, opts.calibration, [ina]) do publish_interval_ms = hertz_to_ms(opts.publish_rate) state = %{ bb: opts.bb, ina: ina, publish_interval_ms: publish_interval_ms } schedule_tick(publish_interval_ms) {:ok, state} else {:error, reason} -> {:stop, reason} end end @impl BB.Sensor def handle_info(:tick, state) do case read(state.ina) do {:ok, fields} -> frame_id = List.last(state.bb.path) message = Message.new!(PowerState, frame_id, fields) BB.publish(state.bb.robot, [:sensor | state.bb.path], message) {:error, reason} -> Logger.warning("INA219 read failed at #{inspect(state.bb.path)}: #{inspect(reason)}") end schedule_tick(state.publish_interval_ms) {:noreply, state} end @impl BB.Sensor def handle_options(new_opts, state) do new_opts = Map.new(new_opts) publish_interval_ms = hertz_to_ms(new_opts.publish_rate) {:ok, %{state | publish_interval_ms: publish_interval_ms}} end defp read(ina) do with {:ok, bus_v} <- INA219.bus_voltage(ina), {:ok, current_ma} <- INA219.current(ina), {:ok, power_mw} <- INA219.power(ina), {:ok, shunt_mv} <- INA219.shunt_voltage(ina) do {:ok, [ voltage: bus_v, current: current_ma / 1000.0, power: power_mw / 1000.0, shunt_voltage: shunt_mv / 1000.0 ]} end end defp divisors_for(:calibrate_32V_2A), do: {10, 2} defp divisors_for(:calibrate_32V_1A), do: {25, 1} defp divisors_for(:calibrate_16V_400mA), do: {20, 1} defp hertz_to_ms(rate) do rate |> Unit.convert!("hertz") |> Units.extract_float() |> then(&round(1000 / &1)) end defp schedule_tick(ms) do Process.send_after(self(), :tick, ms) end end