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, <>} <- 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, <>} <- 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, <>} <- 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, <>} <- 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 <> = 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, <>} <- 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, <
>, @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, <>, []) 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