Skip to main content

lib/stk31862.ex

# SPDX-FileCopyrightText: 2026 Ben Youngblood
#
# SPDX-License-Identifier: Apache-2.0

defmodule STK31862 do
  @moduledoc File.read!("README.md")
             |> String.split("<!-- MODULEDOC -->")
             |> Enum.fetch!(1)

  use GenServer
  alias STK31862.Comm
  alias STK31862.Measurement
  require Logger

  @typedoc """
  STK31862 GenServer start options.

  * `:name` - a name for the GenServer
  * `:bus_name` - which I2C bus to use (e.g., `"i2c-1"`, the default)
  * `:bus_address` - the I2C address (the STK31862 is fixed at `0x45`)
  * `:retries` - the number of I2C retries before failing (defaults to none)
  * `:poll_interval_ms` - how often to poll the sensor (defaults to `1000`)
  * `:gain` - ALS gain (defaults to `:gain_1x`)
  * `:integration_time` - ALS integration time (defaults to `:it_100ms`)
  * `:persistence` - out-of-window interrupt persistence (defaults to `:persistence_1`)
  """
  @type option ::
          {:name, atom}
          | {:bus_name, String.t()}
          | {:bus_address, 0x45}
          | {:retries, non_neg_integer}
          | {:poll_interval_ms, pos_integer}
          | {:gain, gain}
          | {:integration_time, integration_time}
          | {:persistence, persistence}

  @typedoc """
  ALS/C gain setting. `:gain_128x` is the special DX128 gain.
  """
  @type gain :: :gain_1x | :gain_4x | :gain_16x | :gain_64x | :gain_128x

  @typedoc """
  ALS integration time setting. The atom names spell the time in milliseconds,
  e.g. `:it_3_125ms` is 3.125 ms and `:it_100ms` is 100 ms. Longer times give
  finer resolution.
  """
  @type integration_time ::
          :it_3_125ms
          | :it_6_25ms
          | :it_12_5ms
          | :it_25ms
          | :it_50ms
          | :it_100ms
          | :it_200ms
          | :it_400ms

  @typedoc """
  Out-of-window interrupt persistence: how many consecutive out-of-window
  conversions must occur before the interrupt is asserted.
  """
  @type persistence :: :persistence_1 | :persistence_2 | :persistence_4 | :persistence_8

  @typedoc """
  Sensor configuration map, as returned by `get_config/1`.

  `set_config/2` accepts the same fields (as a map or keyword list); any field
  left out keeps its current value.

  * `:gain` - ALS gain (see `t:gain/0`)
  * `:integration_time` - ALS integration time (see `t:integration_time/0`)
  * `:persistence` - out-of-window interrupt persistence (see `t:persistence/0`)
  * `:c_gain` - C-channel gain (see `t:gain/0`)
  """
  @type config :: %{
          gain: gain,
          integration_time: integration_time,
          persistence: persistence,
          c_gain: gain
        }

  @default_bus_name "i2c-1"
  @default_bus_address 0x45
  @default_poll_interval_ms 1000
  @default_gain :gain_1x
  @default_integration_time :it_100ms
  @default_persistence :persistence_1

  ## Public API

  @doc "Starts a GenServer that manages a STK31862 sensor."
  @spec start_link([option]) :: GenServer.on_start()
  def start_link(options \\ []) do
    GenServer.start_link(__MODULE__, options, name: options[:name])
  end

  @doc """
  Returns the latest measurement.

  `{:error, :no_measurement}` is returned until the first reading completes.
  """
  @spec measure(GenServer.server()) :: {:ok, Measurement.t()} | {:error, any}
  def measure(server \\ __MODULE__) do
    GenServer.call(server, :measure)
  end

  @doc """
  Returns the current configuration map: `:gain`, `:integration_time`,
  `:persistence` and `:c_gain`.
  """
  @spec get_config(GenServer.server()) :: {:ok, config()} | {:error, any}
  def get_config(server \\ __MODULE__) do
    GenServer.call(server, :get_config)
  end

  @doc """
  Updates the configuration.

  `changes` may set `:gain`, `:integration_time`, `:persistence` and `:c_gain`.

      iex> STK31862.set_config(server, gain: :gain_16x, integration_time: :it_200ms)
  """
  @spec set_config(GenServer.server(), keyword | map) :: {:ok, config()} | {:error, any}
  def set_config(server \\ __MODULE__, changes) do
    GenServer.call(server, {:set_config, changes})
  end

  @doc "Reads the product ID register."
  @spec read_product_id(GenServer.server()) :: {:ok, 0..0xFF} | {:error, any}
  def read_product_id(server \\ __MODULE__) do
    GenServer.call(server, :read_product_id)
  end

  @doc "Returns the ALS out-of-window low and high thresholds."
  @spec get_thresholds(GenServer.server()) ::
          {:ok, %{low: 0..0xFFFF, high: 0..0xFFFF}} | {:error, any}
  def get_thresholds(server \\ __MODULE__) do
    GenServer.call(server, :get_thresholds)
  end

  @doc "Sets the ALS out-of-window low and high thresholds (raw counts)."
  @spec set_thresholds(GenServer.server(), 0..0xFFFF, 0..0xFFFF) :: :ok | {:error, any}
  def set_thresholds(server \\ __MODULE__, low, high) do
    GenServer.call(server, {:set_thresholds, low, high})
  end

  @doc "Enables or disables the ALS out-of-window interrupt."
  @spec set_window_interrupt(GenServer.server(), boolean) :: :ok | {:error, any}
  def set_window_interrupt(server \\ __MODULE__, enabled?) do
    GenServer.call(server, {:set_window_interrupt, enabled?})
  end

  @doc "Enables or disables the ALS data-ready interrupt."
  @spec set_data_ready_interrupt(GenServer.server(), boolean) :: :ok | {:error, any}
  def set_data_ready_interrupt(server \\ __MODULE__, enabled?) do
    GenServer.call(server, {:set_data_ready_interrupt, enabled?})
  end

  @doc "Clears a pending ALS out-of-window interrupt flag."
  @spec clear_interrupt(GenServer.server()) :: :ok | {:error, any}
  def clear_interrupt(server \\ __MODULE__) do
    GenServer.call(server, :clear_interrupt)
  end

  @doc "Software-resets the sensor and re-applies the current configuration."
  @spec reset(GenServer.server()) :: :ok | {:error, any}
  def reset(server \\ __MODULE__) do
    GenServer.call(server, :reset)
  end

  ## GenServer callbacks

  @impl GenServer
  def init(init_args) do
    bus_name = init_args[:bus_name] || @default_bus_name
    bus_address = init_args[:bus_address] || @default_bus_address
    poll_interval_ms = init_args[:poll_interval_ms] || @default_poll_interval_ms
    i2c_options = Keyword.take(init_args, [:retries])

    config = %{
      gain: init_args[:gain] || @default_gain,
      integration_time: init_args[:integration_time] || @default_integration_time,
      persistence: init_args[:persistence] || @default_persistence
    }

    Logger.info(
      "STK31862: starting on bus #{bus_name} at address #{inspect(bus_address, base: :hex)}"
    )

    case Comm.init_transport(bus_name, bus_address, i2c_options) do
      {:ok, transport} ->
        state = %{
          transport: transport,
          last_measurement: nil,
          poll_interval_ms: poll_interval_ms,
          config: nil,
          als_lux_per_count: nil
        }

        {:ok, state, {:continue, {:initialize_device, config}}}

      {:error, error} ->
        {:stop, error}
    end
  end

  @impl GenServer
  def handle_continue({:initialize_device, config}, state) do
    with {:ok, applied} <- Comm.write_config(state.transport, config),
         :ok <- Comm.set_als_enabled(state.transport, true) do
      send(self(), :perform_measurement)
      {:noreply, store_config(state, applied)}
    else
      {:error, error} -> {:stop, error, state}
    end
  end

  def handle_continue(:schedule_next_run, state) do
    Process.send_after(self(), :perform_measurement, state.poll_interval_ms)
    {:noreply, state}
  end

  @impl GenServer
  def handle_call(:measure, _from, state) when is_nil(state.last_measurement) do
    {:reply, {:error, :no_measurement}, state}
  end

  def handle_call(:measure, _from, state) do
    {:reply, {:ok, state.last_measurement}, state}
  end

  def handle_call(:get_config, _from, state) do
    {:reply, Comm.read_config(state.transport), state}
  end

  def handle_call({:set_config, changes}, _from, state) do
    case Comm.write_config(state.transport, changes) do
      {:ok, applied} = result ->
        {:reply, result, store_config(state, applied)}

      {:error, _} = error ->
        {:reply, error, state}
    end
  end

  def handle_call(:read_product_id, _from, state) do
    {:reply, Comm.read_product_id(state.transport), state}
  end

  def handle_call(:get_thresholds, _from, state) do
    {:reply, Comm.read_thresholds(state.transport), state}
  end

  def handle_call({:set_thresholds, low, high}, _from, state) do
    {:reply, Comm.write_thresholds(state.transport, low, high), state}
  end

  def handle_call({:set_window_interrupt, enabled?}, _from, state) do
    {:reply, Comm.set_window_interrupt(state.transport, enabled?), state}
  end

  def handle_call({:set_data_ready_interrupt, enabled?}, _from, state) do
    {:reply, Comm.set_data_ready_interrupt(state.transport, enabled?), state}
  end

  def handle_call(:clear_interrupt, _from, state) do
    {:reply, Comm.clear_interrupt(state.transport), state}
  end

  def handle_call(:reset, _from, state) do
    # A soft reset returns every register to its default, so re-apply the
    # configuration this server was managing and re-enable ALS sensing.
    with :ok <- Comm.soft_reset(state.transport),
         {:ok, applied} <- Comm.write_config(state.transport, state.config),
         :ok <- Comm.set_als_enabled(state.transport, true) do
      {:reply, :ok, store_config(state, applied)}
    else
      {:error, _} = error -> {:reply, error, state}
    end
  end

  @impl GenServer
  def handle_info(:perform_measurement, state) do
    new_state =
      case Comm.read_measurement(state.transport, state.als_lux_per_count) do
        {:ok, measurement} ->
          %{state | last_measurement: measurement}

        {:error, reason} ->
          Logger.warning("STK31862: measurement failed: #{inspect(reason)}")
          state
      end

    {:noreply, new_state, {:continue, :schedule_next_run}}
  end

  defp store_config(state, config) do
    %{state | config: config, als_lux_per_count: Comm.als_lux_per_count(config)}
  end
end