Skip to main content

lib/bmi323.ex

defmodule BMI323 do
  @moduledoc """
  Driver for the Bosch BMI323 6-DoF inertial measurement unit.

  Communicates over I²C (or any other [Wafer](https://hex.pm/packages/wafer)
  transport that implements the `Wafer.I2C` protocol).

  ## Protocol notes

  The BMI323 uses an unusual word-oriented register protocol:

    * Every register is 16 bits wide, little-endian.
    * Every read returns **two dummy bytes** before the payload; this driver
      strips them transparently.
    * Writes have no dummy bytes — just the 8-bit address followed by the
      16-bit value(s).
    * Multi-word reads and writes auto-increment the register address.

  ## Example

      {:ok, i2c} = Wafer.Driver.Circuits.I2C.acquire(bus_name: "i2c-1", address: 0x68)
      {:ok, bmi} = BMI323.acquire(conn: i2c)
  """

  alias BMI323.Config
  alias BMI323.Registers
  alias Wafer.Chip
  alias Wafer.Conn

  defstruct conn: nil, accelerometer_range: nil, gyroscope_range: nil

  @type t :: %__MODULE__{
          conn: Conn.t(),
          accelerometer_range: Config.accelerometer_range() | nil,
          gyroscope_range: Config.gyroscope_range() | nil
        }
  @type chip_id :: byte
  @type axes :: %{x: float, y: float, z: float}
  @type imu_sample :: %{accelerometer: axes, gyroscope: axes, temperature: float}
  @type acquire_option ::
          {:conn, Conn.t()}
          | {:soft_reset, boolean}
          | {:verify_chip_id, boolean}

  @behaviour Wafer.Conn

  @default_i2c_address 0x68
  @expected_chip_id 0x43
  @soft_reset_command_le <<0xAF, 0xDE>>
  @post_reset_delay_ms 10
  @gravity_ms2 9.80665
  @sensor_time_tick_us 39.0625

  @acc_data_start 0x03
  @imu_burst_bytes 14

  @doc """
  The default 7-bit I²C address (`0x68`, SDO pin tied to GND).

  The alternate address `0x69` is selected by tying SDO to VDDIO.
  """
  @spec default_i2c_address() :: 0x68
  def default_i2c_address, do: @default_i2c_address

  @doc """
  The expected `CHIP_ID` value (`0x43`) returned by an unmodified BMI323.
  """
  @spec expected_chip_id() :: 0x43
  def expected_chip_id, do: @expected_chip_id

  @doc """
  Wrap an existing Wafer connection in a `BMI323` struct.

  ## Options

    * `:conn` (required) — a Wafer connection that implements the `Wafer.I2C`
      protocol, e.g. `Wafer.Driver.Circuits.I2C` or `Wafer.Driver.Fake`.
    * `:soft_reset` (default `false`) — when `true`, issue a soft reset
      immediately after wrapping the connection. See `soft_reset/1`.
    * `:verify_chip_id` (default `true`) — when `true`, read `CHIP_ID` and
      return `{:error, {:chip_id_mismatch, got: byte, expected: 0x43}}` if the
      device does not identify as a BMI323.
  """
  @impl Wafer.Conn
  @spec acquire([acquire_option]) :: {:ok, t} | {:error, term}
  def acquire(opts) when is_list(opts) do
    with {:ok, conn} <- fetch_conn(opts),
         bmi = %__MODULE__{conn: conn},
         {:ok, bmi} <- maybe_soft_reset(bmi, opts) do
      maybe_verify_chip_id(bmi, opts)
    end
  end

  @doc """
  Read the device's `CHIP_ID` register, returning the 7-bit identifier
  (high byte of the 16-bit register is reserved and discarded).
  """
  @spec chip_id(t) :: {:ok, chip_id} | {:error, term}
  def chip_id(%__MODULE__{} = bmi) do
    with {:ok, <<id, _reserved>>} <- Registers.read_chip_id(bmi) do
      {:ok, id}
    end
  end

  @doc """
  Issue a soft reset by writing the `0xDEAF` command to the `CMD` register.

  The device returns to suspend mode and all user configuration is restored
  to its power-on default. Blocks for #{@post_reset_delay_ms} ms to satisfy
  the device's post-reset start-up time before returning.
  """
  @spec soft_reset(t) :: {:ok, t} | {:error, term}
  def soft_reset(%__MODULE__{} = bmi) do
    with {:ok, bmi} <- Registers.write_cmd(bmi, @soft_reset_command_le) do
      Process.sleep(@post_reset_delay_ms)
      {:ok, bmi}
    end
  end

  @doc """
  Configure the accelerometer and cache the chosen range for later scaling.

  See `BMI323.Config.encode_acc_conf/1` for the supported options. `:mode` and
  `:odr` are required; `:range`, `:bandwidth`, and `:averaging` have defaults.
  """
  @spec configure_accelerometer(t, keyword) :: {:ok, t} | {:error, term}
  def configure_accelerometer(%__MODULE__{} = bmi, opts) when is_list(opts) do
    data = Config.encode_acc_conf(opts)
    range = Keyword.get(opts, :range, 8)

    with {:ok, bmi} <- Registers.write_acc_conf(bmi, data) do
      {:ok, %{bmi | accelerometer_range: range}}
    end
  end

  @doc """
  Configure the gyroscope and cache the chosen range for later scaling.

  See `BMI323.Config.encode_gyr_conf/1` for the supported options.
  """
  @spec configure_gyroscope(t, keyword) :: {:ok, t} | {:error, term}
  def configure_gyroscope(%__MODULE__{} = bmi, opts) when is_list(opts) do
    data = Config.encode_gyr_conf(opts)
    range = Keyword.get(opts, :range, 2000)

    with {:ok, bmi} <- Registers.write_gyr_conf(bmi, data) do
      {:ok, %{bmi | gyroscope_range: range}}
    end
  end

  @doc """
  Populate the cached ranges by reading `ACC_CONF` and `GYR_CONF`.

  Useful after `acquire/1` when the device has already been configured by
  some other process and you need to scale samples without reconfiguring.
  """
  @spec detect_ranges(t) :: {:ok, t} | {:error, term}
  def detect_ranges(%__MODULE__{} = bmi) do
    with {:ok, acc_data} <- Registers.read_acc_conf(bmi),
         {:ok, gyr_data} <- Registers.read_gyr_conf(bmi) do
      %{range: acc_range} = Config.decode_acc_conf(acc_data)
      %{range: gyr_range} = Config.decode_gyr_conf(gyr_data)
      {:ok, %{bmi | accelerometer_range: acc_range, gyroscope_range: gyr_range}}
    end
  end

  @doc """
  Read the accelerometer x/y/z sample and return scaled values in m/s².

  Requires the accelerometer range to be cached on the struct — call
  `configure_accelerometer/2` or `detect_ranges/1` first.
  """
  @spec read_accelerometer(t) :: {:ok, axes} | {:error, term}
  def read_accelerometer(%__MODULE__{accelerometer_range: nil}),
    do: {:error, :accelerometer_range_not_set}

  def read_accelerometer(%__MODULE__{accelerometer_range: range} = bmi) do
    with {:ok, <<x::little-signed-16, y::little-signed-16, z::little-signed-16>>} <-
           Chip.read_register(bmi, @acc_data_start, 6) do
      {:ok, %{x: accel_to_ms2(x, range), y: accel_to_ms2(y, range), z: accel_to_ms2(z, range)}}
    end
  end

  @doc """
  Read the gyroscope x/y/z sample and return scaled values in rad/s.

  Requires the gyroscope range to be cached on the struct — call
  `configure_gyroscope/2` or `detect_ranges/1` first.
  """
  @spec read_gyroscope(t) :: {:ok, axes} | {:error, term}
  def read_gyroscope(%__MODULE__{gyroscope_range: nil}),
    do: {:error, :gyroscope_range_not_set}

  def read_gyroscope(%__MODULE__{gyroscope_range: range} = bmi) do
    with {:ok, <<x::little-signed-16, y::little-signed-16, z::little-signed-16>>} <-
           Chip.read_register(bmi, 0x06, 6) do
      {:ok, %{x: gyro_to_rads(x, range), y: gyro_to_rads(y, range), z: gyro_to_rads(z, range)}}
    end
  end

  @doc """
  Read the temperature sample and return °C. Returns `{:error, :invalid_sample}`
  if the device reports the invalid sentinel `0x8000`.
  """
  @spec read_temperature(t) :: {:ok, float} | {:error, term}
  def read_temperature(%__MODULE__{} = bmi) do
    with {:ok, <<raw::little-signed-16>>} <- Registers.read_temp_data(bmi) do
      decode_temperature(raw)
    end
  end

  @doc """
  Read accelerometer + gyroscope + temperature in a single 7-word burst.

  Requires both ranges to be cached on the struct.
  """
  @spec read_imu(t) :: {:ok, imu_sample} | {:error, term}
  def read_imu(%__MODULE__{accelerometer_range: nil}),
    do: {:error, :accelerometer_range_not_set}

  def read_imu(%__MODULE__{gyroscope_range: nil}),
    do: {:error, :gyroscope_range_not_set}

  def read_imu(%__MODULE__{accelerometer_range: acc_range, gyroscope_range: gyr_range} = bmi) do
    with {:ok, payload} <- Chip.read_register(bmi, @acc_data_start, @imu_burst_bytes) do
      <<ax::little-signed-16, ay::little-signed-16, az::little-signed-16, gx::little-signed-16,
        gy::little-signed-16, gz::little-signed-16, temp::little-signed-16>> = payload

      with {:ok, temperature} <- decode_temperature(temp) do
        {:ok,
         %{
           accelerometer: %{
             x: accel_to_ms2(ax, acc_range),
             y: accel_to_ms2(ay, acc_range),
             z: accel_to_ms2(az, acc_range)
           },
           gyroscope: %{
             x: gyro_to_rads(gx, gyr_range),
             y: gyro_to_rads(gy, gyr_range),
             z: gyro_to_rads(gz, gyr_range)
           },
           temperature: temperature
         }}
      end
    end
  end

  @doc """
  Read the 32-bit sensor time counter.

  One LSB equals #{@sensor_time_tick_us} µs. Multiply by
  `sensor_time_tick_us/0` to get microseconds.
  """
  @spec read_sensor_time(t) :: {:ok, non_neg_integer} | {:error, term}
  def read_sensor_time(%__MODULE__{} = bmi) do
    with {:ok, <<ticks::little-32>>} <- Chip.read_register(bmi, 0x0A, 4) do
      {:ok, ticks}
    end
  end

  @doc "The sensor time counter's tick period in microseconds (#{@sensor_time_tick_us} µs)."
  @spec sensor_time_tick_us() :: float
  def sensor_time_tick_us, do: @sensor_time_tick_us

  defp accel_to_ms2(raw, range_g) do
    raw * @gravity_ms2 * range_g / 32_768
  end

  defp gyro_to_rads(raw, range_dps) do
    raw * :math.pi() * range_dps / (32_768 * 180)
  end

  defp decode_temperature(-32_768), do: {:error, :invalid_sample}
  defp decode_temperature(raw), do: {:ok, raw / 512 + 23}

  defp fetch_conn(opts) do
    case Keyword.fetch(opts, :conn) do
      {:ok, conn} -> {:ok, conn}
      :error -> {:error, "`:conn` option is required"}
    end
  end

  defp maybe_soft_reset(bmi, opts) do
    if Keyword.get(opts, :soft_reset, false), do: soft_reset(bmi), else: {:ok, bmi}
  end

  defp maybe_verify_chip_id(bmi, opts) do
    if Keyword.get(opts, :verify_chip_id, true), do: verify_chip_id(bmi), else: {:ok, bmi}
  end

  defp verify_chip_id(bmi) do
    case chip_id(bmi) do
      {:ok, @expected_chip_id} -> {:ok, bmi}
      {:ok, got} -> {:error, {:chip_id_mismatch, got: got, expected: @expected_chip_id}}
      {:error, _} = error -> error
    end
  end
end

defimpl Wafer.Chip, for: BMI323 do
  @moduledoc """
  `Wafer.Chip` implementation that adapts the BMI323's word-oriented register
  protocol to Wafer's byte-oriented API.

  All register payloads must be a whole number of 16-bit little-endian words.
  Reads transparently skip the two dummy bytes prepended by the device.
  """

  alias Wafer.I2C

  @dummy_bytes 2

  def read_register(%BMI323{conn: inner}, address, bytes)
      when is_integer(address) and address in 0..0xFF and
             is_integer(bytes) and bytes > 0 and rem(bytes, 2) == 0 do
    with {:ok, payload, _inner} <- I2C.write_read(inner, <<address>>, @dummy_bytes + bytes, []) do
      <<_dummy::binary-size(@dummy_bytes), data::binary-size(^bytes)>> = payload
      {:ok, data}
    end
  end

  def read_register(_conn, address, bytes) do
    {:error,
     "Invalid argument: address=#{inspect(address)} bytes=#{inspect(bytes)} " <>
       "(bytes must be a positive even integer, address in 0..0xFF)"}
  end

  def write_register(%BMI323{conn: inner} = conn, address, data)
      when is_integer(address) and address in 0..0xFF and
             is_binary(data) and byte_size(data) > 0 and rem(byte_size(data), 2) == 0 do
    with {:ok, inner} <- I2C.write(inner, <<address, data::binary>>, []) do
      {:ok, %{conn | conn: inner}}
    end
  end

  def write_register(_conn, address, data) do
    {:error,
     "Invalid argument: address=#{inspect(address)} data=#{inspect(data)} " <>
       "(data must be a non-empty binary with an even byte count, address in 0..0xFF)"}
  end

  def swap_register(conn, address, data) when is_binary(data) do
    with {:ok, old} <- read_register(conn, address, byte_size(data)),
         {:ok, conn} <- write_register(conn, address, data) do
      {:ok, old, conn}
    end
  end

  def swap_register(_conn, _address, data),
    do: {:error, "Invalid argument: data must be a binary, got #{inspect(data)}"}
end