Skip to main content

lib/lis3dh.ex

defmodule LIS3DH do
  @moduledoc """
  Driver for the STMicroelectronics LIS3DH 3-axis MEMS accelerometer.

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

  ## Protocol notes

  The LIS3DH uses a standard byte-oriented I²C register protocol. Multi-byte
  reads and writes only auto-increment the register address when bit 7 of the
  sub-address is set; this driver sets that bit unconditionally on every
  transaction, which is harmless for single-byte access and required for
  bursts.

  ## I²C address

  The 7-bit address is `0b0011000x` where `x` is the value of the `SA0` pin
  (also called `SDO`):

    * `SA0 = GND` → `0x18` (default).
    * `SA0 = VDD` → `0x19`.

  ## Example

      {:ok, i2c} = Wafer.Driver.Circuits.I2C.acquire(bus_name: "i2c-1", address: 0x18)
      {:ok, acc} = LIS3DH.acquire(conn: i2c)
  """

  import Bitwise

  alias LIS3DH.Click
  alias LIS3DH.Config
  alias LIS3DH.Interrupts
  alias LIS3DH.Registers
  alias Wafer.Chip
  alias Wafer.Conn

  defstruct conn: nil, operating_mode: nil, range: nil

  @type t :: %__MODULE__{
          conn: Conn.t(),
          operating_mode: Config.operating_mode() | nil,
          range: Config.range() | nil
        }
  @type who_am_i :: byte
  @type axes :: %{x: float, y: float, z: float}
  @type acquire_option ::
          {:conn, Conn.t()}
          | {:verify_who_am_i, boolean}
          | {:reboot, boolean}

  @behaviour Wafer.Conn

  @default_i2c_address 0x18
  @expected_who_am_i 0x33
  @boot_delay_ms 5
  @ctrl_reg_5_boot_bit 7
  @gravity_ms2 9.80665
  @out_x_l 0x28
  @out_adc1_l 0x08
  @aux_adc_center_mv 1200
  @aux_adc_span_mv 400
  @temp_en_bit 6
  @adc_en_bit 7

  @doc """
  The default 7-bit I²C address (`0x18`, SA0 pin tied to GND). The alternate
  address `0x19` is selected by tying SA0 to VDD.
  """
  @spec default_i2c_address() :: 0x18
  def default_i2c_address, do: @default_i2c_address

  @doc """
  The expected `WHO_AM_I` value (`0x33`) returned by an unmodified LIS3DH.
  """
  @spec expected_who_am_i() :: 0x33
  def expected_who_am_i, do: @expected_who_am_i

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

  ## Options

    * `:conn` (required) — a Wafer connection that implements the `Wafer.I2C`
      protocol, e.g. `Wafer.Driver.Circuits.I2C` or `Wafer.Driver.Fake`.
    * `:verify_who_am_i` (default `true`) — when `true`, read `WHO_AM_I` and
      return `{:error, {:who_am_i_mismatch, got: byte, expected: 0x33}}` if
      the device does not identify as a LIS3DH.
    * `:reboot` (default `false`) — when `true`, set `CTRL_REG5.BOOT` to
      refresh the internal trim registers from non-volatile memory and block
      for #{@boot_delay_ms} ms before returning. Useful after power glitches
      or when you suspect the trim values have been corrupted.
  """
  @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),
         acc = %__MODULE__{conn: conn},
         {:ok, acc} <- maybe_reboot(acc, opts) do
      maybe_verify_who_am_i(acc, opts)
    end
  end

  @doc """
  Read the device's `WHO_AM_I` register.
  """
  @spec who_am_i(t) :: {:ok, who_am_i} | {:error, term}
  def who_am_i(%__MODULE__{} = acc) do
    with {:ok, <<id>>} <- Registers.read_who_am_i(acc) do
      {:ok, id}
    end
  end

  @doc """
  Refresh the internal trim registers from non-volatile memory by setting
  `CTRL_REG5.BOOT`. Blocks for #{@boot_delay_ms} ms to give the device time
  to finish the boot sequence before returning.
  """
  @spec reboot(t) :: {:ok, t} | {:error, term}
  def reboot(%__MODULE__{} = acc) do
    with {:ok, acc} <-
           Registers.update_ctrl_reg_5(acc, fn <<byte>> ->
             <<byte ||| 1 <<< @ctrl_reg_5_boot_bit>>
           end) do
      Process.sleep(@boot_delay_ms)
      {:ok, acc}
    end
  end

  @doc """
  Configure the accelerometer's operating mode, ODR, range, axis enables, and
  block-data-update setting. Caches the chosen `:operating_mode` and `:range`
  on the struct so subsequent reads can scale samples without re-reading the
  config registers.

  See `LIS3DH.Config.encode_ctrl_reg_1/1` and
  `LIS3DH.Config.encode_ctrl_reg_4/1` for the supported options. `:mode` and
  `:odr` are required.

  Writes `CTRL_REG4` first (range / HR / BDU), then `CTRL_REG1` (ODR / LPen /
  axes), so the device is fully reconfigured before sampling resumes.
  """
  @spec configure_accelerometer(t, keyword) :: {:ok, t} | {:error, term}
  def configure_accelerometer(%__MODULE__{} = acc, opts) when is_list(opts) do
    mode = Keyword.fetch!(opts, :mode)
    range = Keyword.get(opts, :range, 2)

    ctrl_reg_1 = Config.encode_ctrl_reg_1(opts)
    ctrl_reg_4 = Config.encode_ctrl_reg_4(opts)

    with {:ok, acc} <- Registers.write_ctrl_reg_4(acc, ctrl_reg_4),
         {:ok, acc} <- Registers.write_ctrl_reg_1(acc, ctrl_reg_1) do
      {:ok, %{acc | operating_mode: mode, range: range}}
    end
  end

  @doc """
  Populate the cached `:operating_mode` and `:range` by reading `CTRL_REG1`
  and `CTRL_REG4`. Useful after `acquire/1` when the device has already been
  configured by some other process.
  """
  @spec detect_configuration(t) :: {:ok, t} | {:error, term}
  def detect_configuration(%__MODULE__{} = acc) do
    with {:ok, ctrl_reg_1} <- Registers.read_ctrl_reg_1(acc),
         {:ok, ctrl_reg_4} <- Registers.read_ctrl_reg_4(acc) do
      %{lpen: lpen} = Config.decode_ctrl_reg_1(ctrl_reg_1)
      %{hr: hr, range: range} = Config.decode_ctrl_reg_4(ctrl_reg_4)
      mode = Config.operating_mode(lpen, hr)
      {:ok, %{acc | operating_mode: mode, range: range}}
    end
  end

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

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

  def read_accelerometer(%__MODULE__{range: nil}),
    do: {:error, :range_not_set}

  def read_accelerometer(%__MODULE__{operating_mode: mode, range: range} = acc) do
    with {:ok, <<x::little-signed-16, y::little-signed-16, z::little-signed-16>>} <-
           Chip.read_register(acc, @out_x_l, 6) do
      {:ok, %{x: scale(x, mode, range), y: scale(y, mode, range), z: scale(z, mode, range)}}
    end
  end

  @doc """
  Set `CTRL_REG1.ODR` to a non-zero rate without changing the other fields,
  bringing the sensor out of power-down. Equivalent to a write to `CTRL_REG1`
  with the chosen ODR while preserving the LPen and axis enable bits.
  """
  @spec power_on(t, Config.odr()) :: {:ok, t} | {:error, term}
  def power_on(%__MODULE__{} = acc, odr) do
    Registers.update_ctrl_reg_1(acc, fn <<byte>> ->
      odr_code = encode_odr!(odr)
      <<odr_code <<< 4 ||| (byte &&& 0x0F)>>
    end)
  end

  @doc """
  Set `CTRL_REG1.ODR` to `0000` (power-down mode), preserving the other
  fields.
  """
  @spec power_off(t) :: {:ok, t} | {:error, term}
  def power_off(%__MODULE__{} = acc) do
    Registers.update_ctrl_reg_1(acc, fn <<byte>> -> <<byte &&& 0x0F>> end)
  end

  @doc """
  Enable the on-chip auxiliary ADC by setting `TEMP_CFG_REG.ADC_EN`. The ADC
  samples at the configured `CTRL_REG1.ODR`. Requires `:block_data_update`
  (`CTRL_REG4.BDU`) to be `:hold` for consistent reads — `configure_accelerometer/2`
  defaults to that already.
  """
  @spec enable_auxiliary_adc(t) :: {:ok, t} | {:error, term}
  def enable_auxiliary_adc(%__MODULE__{} = acc) do
    Registers.update_temp_cfg_reg(acc, fn <<byte>> -> <<byte ||| 1 <<< @adc_en_bit>> end)
  end

  @doc "Clear `TEMP_CFG_REG.ADC_EN`, disabling all three auxiliary ADC channels."
  @spec disable_auxiliary_adc(t) :: {:ok, t} | {:error, term}
  def disable_auxiliary_adc(%__MODULE__{} = acc) do
    Registers.update_temp_cfg_reg(acc, fn <<byte>> ->
      <<byte &&& bnot(1 <<< @adc_en_bit) &&& 0xFF>>
    end)
  end

  @doc """
  Enable the embedded temperature sensor by setting both `TEMP_CFG_REG.ADC_EN`
  and `TEMP_CFG_REG.TEMP_EN`. The temperature reading is routed to channel 3
  of the auxiliary ADC; read it via `read_temperature/1`.
  """
  @spec enable_temperature_sensor(t) :: {:ok, t} | {:error, term}
  def enable_temperature_sensor(%__MODULE__{} = acc) do
    Registers.update_temp_cfg_reg(acc, fn <<_byte>> ->
      <<1 <<< @adc_en_bit ||| 1 <<< @temp_en_bit>>
    end)
  end

  @doc "Clear `TEMP_CFG_REG.TEMP_EN` (leaving `ADC_EN` alone)."
  @spec disable_temperature_sensor(t) :: {:ok, t} | {:error, term}
  def disable_temperature_sensor(%__MODULE__{} = acc) do
    Registers.update_temp_cfg_reg(acc, fn <<byte>> ->
      <<byte &&& bnot(1 <<< @temp_en_bit) &&& 0xFF>>
    end)
  end

  @doc """
  Read auxiliary ADC channel 1, 2, or 3 and return the absolute voltage in
  millivolts.

  The chip's ADC input range is centred on #{@aux_adc_center_mv} mV with a
  ±#{@aux_adc_span_mv} mV span, so the returned value is in
  `#{@aux_adc_center_mv - @aux_adc_span_mv}..#{@aux_adc_center_mv + @aux_adc_span_mv}` mV.
  ADC resolution depends on the operating mode (10-bit in normal /
  high-resolution, 8-bit in low-power), so this function requires
  `:operating_mode` to be cached on the struct.
  """
  @spec read_auxiliary_adc(t, 1 | 2 | 3) :: {:ok, float} | {:error, term}
  def read_auxiliary_adc(%__MODULE__{operating_mode: nil}, _channel),
    do: {:error, :operating_mode_not_set}

  def read_auxiliary_adc(%__MODULE__{operating_mode: mode} = acc, channel)
      when channel in 1..3 do
    address = @out_adc1_l + (channel - 1) * 2

    with {:ok, <<raw::little-signed-16>>} <- Chip.read_register(acc, address, 2) do
      {:ok, scale_aux_adc(raw, mode)}
    end
  end

  @doc """
  Read the embedded temperature sensor on auxiliary ADC channel 3 and return
  the **delta** temperature in °C, relative to the 25 °C factory
  calibration point (i.e. add `25.0` for the absolute reading).

  Only the `OUT_ADC3_H` byte carries temperature data — sensitivity is
  `1 LSB/°C` and resolution is 8-bit regardless of operating mode
  (datasheet §3.2). The full 16-bit word is still read so `BDU=:hold`
  unlatches cleanly.

  Requires the temperature sensor to be enabled via
  `enable_temperature_sensor/1`.
  """
  @spec read_temperature(t) :: {:ok, float} | {:error, term}
  def read_temperature(%__MODULE__{} = acc) do
    with {:ok, <<raw::little-signed-16>>} <- Chip.read_register(acc, @out_adc1_l + 4, 2) do
      {:ok, (raw >>> 8) * 1.0}
    end
  end

  # Data is left-justified — meaningful bits at the MSB end. Arithmetic right
  # shift recovers the native signed N-bit value, then we scale by the
  # per-mode mg/LSB and convert mg → m/s².
  defp scale(raw, mode, range) do
    shift = 16 - Config.native_width(mode)
    sensitivity_mg = Config.sensitivity(mode, range)
    (raw >>> shift) * sensitivity_mg * @gravity_ms2 / 1000
  end

  # Aux ADC is left-justified 10-bit (HR/Normal) or 8-bit (low-power) signed.
  # Recover the N-bit signed value, then map ±full-scale → ±@aux_adc_span_mv
  # added to the @aux_adc_center_mv centre.
  defp scale_aux_adc(raw, mode) do
    width = Config.aux_adc_width(mode)
    shift = 16 - width
    full_scale = 1 <<< (width - 1)
    @aux_adc_center_mv + (raw >>> shift) * @aux_adc_span_mv / full_scale
  end

  defp encode_odr!(odr) do
    Map.fetch!(
      %{
        :power_down => 0b0000,
        1 => 0b0001,
        10 => 0b0010,
        25 => 0b0011,
        50 => 0b0100,
        100 => 0b0101,
        200 => 0b0110,
        400 => 0b0111,
        1600 => 0b1000,
        1344 => 0b1001,
        5376 => 0b1001
      },
      odr
    )
  end

  @doc """
  Configure the on-chip high-pass filter via `CTRL_REG2`.

  See `LIS3DH.Config.encode_ctrl_reg_2/1` for the supported options.
  """
  @spec configure_high_pass_filter(t, keyword) :: {:ok, t} | {:error, term}
  def configure_high_pass_filter(%__MODULE__{} = acc, opts \\ []) do
    Registers.write_ctrl_reg_2(acc, Config.encode_ctrl_reg_2(opts))
  end

  @doc """
  Read the `REFERENCE` register. With `:normal_with_reset` HPF mode (the
  default after power-up), this read also resets the high-pass filter's
  internal state.
  """
  @spec read_reference(t) :: {:ok, integer} | {:error, term}
  def read_reference(%__MODULE__{} = acc) do
    with {:ok, <<value::signed-8>>} <- Registers.read_reference(acc) do
      {:ok, value}
    end
  end

  @doc "Write the `REFERENCE` register (used as the HPF reference in `:reference` mode)."
  @spec write_reference(t, integer) :: {:ok, t} | {:error, term}
  def write_reference(%__MODULE__{} = acc, value) when value in -128..127 do
    Registers.write_reference(acc, <<value::signed-8>>)
  end

  @doc """
  Configure a free-fall detector on the given interrupt pin.

  Free-fall is signalled when the magnitude of acceleration on all three
  axes falls below a threshold for a configurable duration (i.e. the device
  is in true free fall, ~0 g on every axis).

  ## Options

    * `:threshold_mg` — threshold in milli-g (default `350`, the AN3308
      recommended value). Lower thresholds trigger more easily.
    * `:duration` — `0..127` count of `1/ODR` periods (default `5`).
  """
  @spec configure_free_fall(t, Interrupts.pin(), keyword) :: {:ok, t} | {:error, term}
  def configure_free_fall(%__MODULE__{} = acc, pin, opts \\ []) do
    configure_inertial_interrupt(acc, pin,
      mode: :and,
      axes: [:x_low, :y_low, :z_low],
      threshold_mg: Keyword.get(opts, :threshold_mg, 350),
      duration: Keyword.get(opts, :duration, 5)
    )
  end

  @doc """
  Configure a motion (wake-up) detector on the given interrupt pin.

  Motion is signalled when **any** enabled axis exceeds the threshold for
  the configured duration.

  ## Options

    * `:threshold_mg` — threshold in milli-g (no default, must be specified).
    * `:duration` — `0..127` count of `1/ODR` periods (default `0`).
    * `:axes` — list of `t:LIS3DH.Interrupts.axis_event/0` (default
      `[:x_high, :y_high, :z_high]`).
  """
  @spec configure_motion(t, Interrupts.pin(), keyword) :: {:ok, t} | {:error, term}
  def configure_motion(%__MODULE__{} = acc, pin, opts) do
    configure_inertial_interrupt(acc, pin,
      mode: :or,
      axes: Keyword.get(opts, :axes, [:x_high, :y_high, :z_high]),
      threshold_mg: Keyword.fetch!(opts, :threshold_mg),
      duration: Keyword.get(opts, :duration, 0)
    )
  end

  @doc """
  Configure 6D or 4D orientation detection on the given interrupt pin.

  ## Options

    * `:mode` — `:movement` (interrupt fires on transitions between known
      zones) or `:position` (interrupt stays asserted while inside a known
      zone). Default `:position`.
    * `:detection` — `:six_d` (default, all six face-down/face-up directions)
      or `:four_d` (X/Y plane only, Z ignored — for portrait/landscape).
    * `:axes` — list of `t:LIS3DH.Interrupts.axis_event/0` to enable
      (default all six).
    * `:threshold_mg` — threshold in milli-g (no default; the zone half-width
      is typically chosen so two zones don't overlap).
    * `:duration` — `0..127` count of `1/ODR` periods (default `0`).

  Writes the configured `INT*_CFG`, `INT*_THS`, `INT*_DURATION` and also
  toggles `CTRL_REG5.D4D_INT*` to match the `:detection` choice.
  """
  @spec configure_orientation(t, Interrupts.pin(), keyword) :: {:ok, t} | {:error, term}
  def configure_orientation(%__MODULE__{} = acc, pin, opts) do
    aoi_mode =
      case Keyword.get(opts, :mode, :position) do
        :movement -> :six_d_movement
        :position -> :six_d_position
      end

    detection = Keyword.get(opts, :detection, :six_d)

    axes =
      Keyword.get(opts, :axes, [:x_high, :x_low, :y_high, :y_low, :z_high, :z_low])

    with {:ok, acc} <-
           configure_inertial_interrupt(acc, pin,
             mode: aoi_mode,
             axes: axes,
             threshold_mg: Keyword.fetch!(opts, :threshold_mg),
             duration: Keyword.get(opts, :duration, 0)
           ) do
      set_4d_detection(acc, pin, detection == :four_d)
    end
  end

  @doc """
  Configure sleep-to-wake / return-to-sleep by writing `ACT_THS` and
  `ACT_DUR`.

  When acceleration falls below `:threshold_mg` for the configured
  `:duration`, the device automatically switches to low-power mode at 10 Hz
  ODR regardless of the original `CTRL_REG1` / `CTRL_REG4` settings. When
  acceleration rises above the threshold, the device restores the original
  configuration.

  ## Options

    * `:threshold_mg` — threshold in milli-g (required). Uses the same LSB
      table as `INT*_THS`. Pass `0` to disable activity detection.
    * `:duration` — `0..255` (required). One LSB corresponds to
      `(8 × duration + 1) / ODR` seconds per datasheet §8.36.

  Requires the accelerometer range to be cached on the struct.
  """
  @spec configure_activity(t, keyword) :: {:ok, t} | {:error, term}
  def configure_activity(%__MODULE__{range: nil}, _opts), do: {:error, :range_not_set}

  def configure_activity(%__MODULE__{range: range} = acc, opts) do
    threshold_mg = Keyword.fetch!(opts, :threshold_mg)
    duration = Keyword.fetch!(opts, :duration)

    unless is_integer(duration) and duration in 0..255,
      do: raise(ArgumentError, "invalid duration: #{inspect(duration)} (must be 0..255)")

    ths = Interrupts.encode_threshold!(threshold_mg, range)

    with {:ok, acc} <- Registers.write_act_ths(acc, ths) do
      Registers.write_act_dur(acc, <<duration>>)
    end
  end

  @doc "Disable activity detection by writing `0` to `ACT_THS`."
  @spec disable_activity(t) :: {:ok, t} | {:error, term}
  def disable_activity(%__MODULE__{} = acc) do
    Registers.write_act_ths(acc, <<0>>)
  end

  @doc """
  Configure click / double-click / tap detection by writing `CLICK_CFG`,
  `CLICK_THS`, `TIME_LIMIT`, `TIME_LATENCY`, and `TIME_WINDOW`.

  ## Options

    * `:events` — list of `t:LIS3DH.Click.click_event/0` to enable
      (required; pass `[]` to disable all).
    * `:threshold_mg` — threshold in milli-g (required). Same LSB table as
      `INT*_THS`.
    * `:latched` — when `true`, the click interrupt stays high until
      `CLICK_SRC` is read (default `false`).
    * `:time_limit` — `0..127` count of `1/ODR` periods, the max click pulse
      width (required).
    * `:time_latency` — `0..255` count of `1/ODR` periods, the dead time
      after a click (required).
    * `:time_window` — `0..255` count of `1/ODR` periods, the search window
      for the second click of a double-click (default `0`).

  Requires the accelerometer range to be cached on the struct.
  """
  @spec configure_click(t, keyword) :: {:ok, t} | {:error, term}
  def configure_click(%__MODULE__{range: nil}, _opts), do: {:error, :range_not_set}

  def configure_click(%__MODULE__{range: range} = acc, opts) do
    events = Keyword.fetch!(opts, :events)
    threshold_mg = Keyword.fetch!(opts, :threshold_mg)
    latched = Keyword.get(opts, :latched, false)
    time_limit = Keyword.fetch!(opts, :time_limit)
    time_latency = Keyword.fetch!(opts, :time_latency)
    time_window = Keyword.get(opts, :time_window, 0)

    unless is_integer(time_limit) and time_limit in 0..127,
      do: raise(ArgumentError, "invalid time_limit: #{inspect(time_limit)} (must be 0..127)")

    unless is_integer(time_latency) and time_latency in 0..255,
      do: raise(ArgumentError, "invalid time_latency: #{inspect(time_latency)} (must be 0..255)")

    unless is_integer(time_window) and time_window in 0..255,
      do: raise(ArgumentError, "invalid time_window: #{inspect(time_window)} (must be 0..255)")

    with {:ok, acc} <-
           Registers.write_click_ths(acc, Click.encode_click_ths!(threshold_mg, range, latched)),
         {:ok, acc} <- Registers.write_time_limit(acc, <<time_limit>>),
         {:ok, acc} <- Registers.write_time_latency(acc, <<time_latency>>),
         {:ok, acc} <- Registers.write_time_window(acc, <<time_window>>) do
      Registers.write_click_cfg(acc, Click.encode_click_cfg(events))
    end
  end

  @doc """
  Read the `CLICK_SRC` register and decode it. Reading clears the latched
  flags if `LIR_Click` was set during configure.
  """
  @spec read_click_source(t) :: {:ok, Click.source_flags()} | {:error, term}
  def read_click_source(%__MODULE__{} = acc) do
    with {:ok, byte} <- Registers.read_click_src(acc) do
      {:ok, Click.decode_click_src(byte)}
    end
  end

  @doc """
  Configure an inertial interrupt (1 or 2) by writing `INT*_CFG`, `INT*_THS`,
  and `INT*_DURATION` atomically.

  ## Options

    * `:mode` — `t:LIS3DH.Interrupts.aoi_mode/0` (default `:or`).
    * `:axes` — list of `t:LIS3DH.Interrupts.axis_event/0` to enable.
    * `:threshold_mg` — non-negative integer threshold in milli-g. The
      LSB size depends on the cached `:range`; this function reads the
      cached value and rounds the threshold to fit.
    * `:duration` — `0..127` count of `1/ODR` periods the condition must
      hold before the interrupt fires (default `0`).

  Requires the accelerometer range to be cached on the struct.
  """
  @spec configure_inertial_interrupt(t, Interrupts.pin(), keyword) :: {:ok, t} | {:error, term}
  def configure_inertial_interrupt(%__MODULE__{range: nil}, _pin, _opts),
    do: {:error, :range_not_set}

  def configure_inertial_interrupt(%__MODULE__{range: range} = acc, pin, opts)
      when pin in [:int1, :int2] do
    cfg = Interrupts.encode_int_cfg(opts)
    ths = Interrupts.encode_threshold!(Keyword.get(opts, :threshold_mg, 0), range)
    dur = Interrupts.encode_duration!(Keyword.get(opts, :duration, 0))

    {cfg_w, ths_w, dur_w} =
      case pin do
        :int1 ->
          {&Registers.write_int1_cfg/2, &Registers.write_int1_ths/2,
           &Registers.write_int1_duration/2}

        :int2 ->
          {&Registers.write_int2_cfg/2, &Registers.write_int2_ths/2,
           &Registers.write_int2_duration/2}
      end

    with {:ok, acc} <- ths_w.(acc, ths),
         {:ok, acc} <- dur_w.(acc, dur) do
      cfg_w.(acc, cfg)
    end
  end

  @doc """
  Read the `INT*_SRC` register. Reading clears the latched flags if latching
  is enabled (`LIR_INTx` in `CTRL_REG5`).
  """
  @spec read_interrupt_source(t, Interrupts.pin()) ::
          {:ok, Interrupts.source_flags()} | {:error, term}
  def read_interrupt_source(%__MODULE__{} = acc, pin) when pin in [:int1, :int2] do
    reader = if pin == :int1, do: &Registers.read_int1_src/1, else: &Registers.read_int2_src/1

    with {:ok, byte} <- reader.(acc) do
      {:ok, Interrupts.decode_int_src(byte)}
    end
  end

  @doc """
  OR-in the given routing bits in `CTRL_REG3` (INT1 routing). Leaves the
  other bits untouched, so it composes cleanly with `LIS3DH.Sampler` which
  also writes the FIFO bits in this register.

  Valid `events`: `:click`, `:ia1`, `:ia2`, `:zyxda`, `:adc_drdy_321`,
  `:fifo_watermark`, `:fifo_overrun`.
  """
  @spec enable_int1_routing(t, [int1_event]) :: {:ok, t} | {:error, term}
        when int1_event:
               :click | :ia1 | :ia2 | :zyxda | :adc_drdy_321 | :fifo_watermark | :fifo_overrun
  def enable_int1_routing(%__MODULE__{} = acc, events) when is_list(events) do
    mask = int1_routing_mask(events)
    Registers.update_ctrl_reg_3(acc, fn <<byte>> -> <<byte ||| mask>> end)
  end

  @doc "Mask out the given routing bits in `CTRL_REG3` (INT1 routing)."
  @spec disable_int1_routing(t, [int1_event]) :: {:ok, t} | {:error, term}
        when int1_event:
               :click | :ia1 | :ia2 | :zyxda | :adc_drdy_321 | :fifo_watermark | :fifo_overrun
  def disable_int1_routing(%__MODULE__{} = acc, events) when is_list(events) do
    mask = int1_routing_mask(events)
    Registers.update_ctrl_reg_3(acc, fn <<byte>> -> <<byte &&& bnot(mask) &&& 0xFF>> end)
  end

  @doc """
  OR-in the given routing bits in `CTRL_REG6` (INT2 routing). Preserves the
  `INT_POLARITY` bit and any others not in `events`.

  Valid `events`: `:click`, `:ia1`, `:ia2`, `:boot`, `:activity`.
  """
  @spec enable_int2_routing(t, [int2_event]) :: {:ok, t} | {:error, term}
        when int2_event: :click | :ia1 | :ia2 | :boot | :activity
  def enable_int2_routing(%__MODULE__{} = acc, events) when is_list(events) do
    mask = int2_routing_mask(events)
    Registers.update_ctrl_reg_6(acc, fn <<byte>> -> <<byte ||| mask>> end)
  end

  @doc "Mask out the given routing bits in `CTRL_REG6` (INT2 routing)."
  @spec disable_int2_routing(t, [int2_event]) :: {:ok, t} | {:error, term}
        when int2_event: :click | :ia1 | :ia2 | :boot | :activity
  def disable_int2_routing(%__MODULE__{} = acc, events) when is_list(events) do
    mask = int2_routing_mask(events)
    Registers.update_ctrl_reg_6(acc, fn <<byte>> -> <<byte &&& bnot(mask) &&& 0xFF>> end)
  end

  @doc """
  Set the active level for both INT pins via `CTRL_REG6.INT_POLARITY`.

  `polarity` is `:active_high` (default after reset) or `:active_low`.
  """
  @spec set_interrupt_polarity(t, :active_high | :active_low) :: {:ok, t} | {:error, term}
  def set_interrupt_polarity(%__MODULE__{} = acc, polarity)
      when polarity in [:active_high, :active_low] do
    bit = if polarity == :active_low, do: 1 <<< 1, else: 0

    Registers.update_ctrl_reg_6(acc, fn <<byte>> ->
      <<(byte &&& bnot(1 <<< 1) &&& 0xFF) ||| bit>>
    end)
  end

  @doc """
  Toggle interrupt latching for the given pin via `CTRL_REG5.LIR_INT1` /
  `LIR_INT2`. When latched, the interrupt pin stays asserted until the
  corresponding `INT*_SRC` register is read.
  """
  @spec set_interrupt_latching(t, Interrupts.pin(), boolean) :: {:ok, t} | {:error, term}
  def set_interrupt_latching(%__MODULE__{} = acc, pin, latched?) when pin in [:int1, :int2] do
    bit = if pin == :int1, do: 3, else: 1
    update_bit(acc, &Registers.update_ctrl_reg_5/2, bit, latched?)
  end

  @doc """
  Toggle 4D detection for the given pin via `CTRL_REG5.D4D_INT1` /
  `D4D_INT2`. 4D restricts 6D detection to the X/Y plane (Z position
  ignored). Has no effect unless `INT*_CFG.6D` is also set.
  """
  @spec set_4d_detection(t, Interrupts.pin(), boolean) :: {:ok, t} | {:error, term}
  def set_4d_detection(%__MODULE__{} = acc, pin, enabled?) when pin in [:int1, :int2] do
    bit = if pin == :int1, do: 2, else: 0
    update_bit(acc, &Registers.update_ctrl_reg_5/2, bit, enabled?)
  end

  defp int1_routing_mask(events) do
    map = %{
      click: 1 <<< 7,
      ia1: 1 <<< 6,
      ia2: 1 <<< 5,
      zyxda: 1 <<< 4,
      adc_drdy_321: 1 <<< 3,
      fifo_watermark: 1 <<< 2,
      fifo_overrun: 1 <<< 1
    }

    Enum.reduce(events, 0, fn event, acc ->
      acc ||| Map.fetch!(map, event)
    end)
  end

  defp int2_routing_mask(events) do
    map = %{
      click: 1 <<< 7,
      ia1: 1 <<< 6,
      ia2: 1 <<< 5,
      boot: 1 <<< 4,
      activity: 1 <<< 3
    }

    Enum.reduce(events, 0, fn event, acc ->
      acc ||| Map.fetch!(map, event)
    end)
  end

  defp update_bit(acc, updater, bit, true),
    do: updater.(acc, fn <<byte>> -> <<byte ||| 1 <<< bit>> end)

  defp update_bit(acc, updater, bit, false),
    do: updater.(acc, fn <<byte>> -> <<byte &&& bnot(1 <<< bit) &&& 0xFF>> end)

  @doc """
  Set the `CTRL_REG4.ST` self-test field while preserving the other bits.

  The recommended self-test procedure (per ST application note AN3308) is:

    1. Power up the device and `configure_accelerometer/2` for normal mode,
       ±2g, 50 Hz, BDU=`:hold`.
    2. Wait for stable output (≥ a few ODR periods) and average several
       baseline samples.
    3. Call `set_self_test(acc, :self_test_0)` and wait for the documented
       turn-on time (90 ms typical).
    4. Average several test samples; the per-axis delta vs. the baseline
       must fall within the limits in datasheet table 4.
    5. Restore with `set_self_test(acc, :off)`.
    6. Optionally repeat with `:self_test_1` for the alternate direction.

  This helper just toggles the ST field; the user owns the timing,
  averaging, and pass/fail check.
  """
  @spec set_self_test(t, Config.self_test_mode()) :: {:ok, t} | {:error, term}
  def set_self_test(%__MODULE__{} = acc, mode) do
    st_code = Config.self_test_code(mode)

    Registers.update_ctrl_reg_4(acc, fn <<byte>> ->
      <<(byte &&& bnot(0b110) &&& 0xFF) ||| st_code <<< 1>>
    end)
  end

  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_reboot(acc, opts) do
    if Keyword.get(opts, :reboot, false), do: reboot(acc), else: {:ok, acc}
  end

  defp maybe_verify_who_am_i(acc, opts) do
    if Keyword.get(opts, :verify_who_am_i, true), do: verify_who_am_i(acc), else: {:ok, acc}
  end

  defp verify_who_am_i(acc) do
    case who_am_i(acc) do
      {:ok, @expected_who_am_i} -> {:ok, acc}
      {:ok, got} -> {:error, {:who_am_i_mismatch, got: got, expected: @expected_who_am_i}}
      {:error, _} = error -> error
    end
  end
end

defimpl Wafer.Chip, for: LIS3DH do
  @moduledoc """
  `Wafer.Chip` implementation that sets bit 7 (auto-increment) of the
  sub-address on every read and write, satisfying the LIS3DH's requirement
  for multi-byte transfers without breaking single-byte access.
  """

  import Bitwise

  alias Wafer.I2C

  @auto_increment 0x80

  def read_register(%LIS3DH{conn: inner}, address, bytes)
      when is_integer(address) and address in 0..0x7F and
             is_integer(bytes) and bytes > 0 do
    with {:ok, data, _inner} <-
           I2C.write_read(inner, <<address ||| @auto_increment>>, bytes, []) do
      {:ok, data}
    end
  end

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

  def write_register(%LIS3DH{conn: inner} = conn, address, data)
      when is_integer(address) and address in 0..0x7F and
             is_binary(data) and byte_size(data) > 0 do
    with {:ok, inner} <-
           I2C.write(inner, <<address ||| @auto_increment, 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)} " <>
       "(address must be in 0..0x7F, data must be a non-empty binary)"}
  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