# 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