defmodule SHT4X do
@moduledoc """
Use Sensirion SHT4X humidity and temperature sensors in Elixir
"""
use GenServer
require Logger
@typedoc """
Compensation callback function
"""
@type compensation_callback :: (SHT4X.Measurement.t() -> SHT4X.Measurement.t()) | nil
@typedoc """
How "fresh" is the sample we fetched from the sensor's GenServer?
In the event that the sensor fails to report back a measurement during a polling interval, we re-use the last sample.
If this continues to happen over a time period that exceeds the `:stale_threshold`, we mark the re-used "current" sample as stale.
The possible values can be:
- `:fresh` - This is a recent sample. See the `:stale_threshold`.
- `:stale` - This is an old sample that should be used with caution.
- `:unusable` - This is a default sample when no measurements are available.
- `:converging` - This is optionally set by the temperature compensation algorithm to indicate that it was recently restarted without historic state information and needs more time to give accurate values
"""
@type quality :: :fresh | :stale | :unusable | :converging
@typedoc """
SHT4X GenServer start_link options
* `:name` - a name for the GenServer
* `:bus_name` - which I2C bus to use (e.g., `"i2c-1"`)
* `:retries` - the number of retries before failing (defaults to 3 retries)
* `:compensation_callback` - a function that takes in a `SHT4X.Measurement.t()` and returns a potentially modified `SHT4X.Measurement.t()`
* `:measurement_interval` - how often data will be read from the sensor (defaults to 5_000 ms)
* `:repeatability` - accuracy of the requested sensor read (`:low`, `:medium`, or `:high`)
* `:stale_threshold` - number of milliseconds a sample can remain the current sample before it is marked stale
* Also accepts all other standard `GenServer` start_link options
"""
@type option ::
{:debug, GenServer.debug()}
| {:name, GenServer.name()}
| {:timeout, timeout()}
| {:spawn_opt, [Process.spawn_opt()]}
| {:hibernate_after, timeout()}
| {:bus_name, binary()}
| {:retries, pos_integer()}
| {:compensation_callback, compensation_callback()}
| {:measurement_interval, pos_integer()}
| {:repeatability, :low | :medium | :high}
| {:stale_threshold, pos_integer()}
@type options :: [option()]
@default_bus_name "i2c-1"
@default_interval 5_000
@default_retries 3
@default_repeatability :high
@default_func &Function.identity/1
@bus_address 0x44
# This is a hard-coded value to be retuning in the very unlikely situation that we have no reading at all
# It's unusable, and marked as such in the `:quality` field.
@hardcoded_value %SHT4X.Measurement{
timestamp_ms: 0,
raw_reading_humidity: 0,
raw_reading_temperature: 0,
temperature_c: 23.0,
humidity_rh: 50,
dew_point_c: 12.02,
quality: :unusable
}
# Default number of milliseconds a sample has to remain the "current" sample before we consider it stale
@default_stale_threshold 60_000
## Public API
@doc """
Start a new GenServer for interacting with a SHT4X.
"""
@spec start_link(options()) :: GenServer.on_start()
def start_link(opts \\ []) do
gen_server_opts = Keyword.take(opts, [:name, :debug, :timeout, :spawn_opt, :hibernate_after])
init_arg =
Keyword.take(opts, [
:bus_name,
:retries,
:compensation_callback,
:measurement_interval,
:repeatability,
:stale_threshold
])
GenServer.start_link(__MODULE__, init_arg, gen_server_opts)
end
@doc """
Fetches the latest sample from the sensor's GenServer
This does not cause an on-demand read from the sensor. Check the `:quality`
field for a quick assessment of how much to trust the measurement.
"""
@spec get_sample(GenServer.server()) :: SHT4X.Measurement.t()
def get_sample(sensor_ref) do
GenServer.call(sensor_ref, :get_sample)
end
@doc """
Return the sensor's serial number
"""
@spec serial_number(GenServer.server()) :: {:ok, 0..0xFFFF_FFFF} | :error
def serial_number(sensor_ref) do
GenServer.call(sensor_ref, :serial_number)
end
## Callbacks
@impl GenServer
def init(init_arg) do
bus_name = init_arg[:bus_name] || @default_bus_name
bus_address = @bus_address
Logger.info(
"[SHT4X] Starting on bus #{bus_name} at address #{inspect(bus_address, base: :hex)}"
)
options = [
retries: Keyword.get(init_arg, :retries, @default_retries),
compensation_callback: Keyword.get(init_arg, :compensation_callback, @default_func),
measurement_interval: Keyword.get(init_arg, :measurement_interval, @default_interval),
repeatability: Keyword.get(init_arg, :repeatability, @default_repeatability),
stale_threshold: Keyword.get(init_arg, :repeatability, @default_stale_threshold)
]
case SHT4X.Transport.new(bus_name, bus_address, options[:retries]) do
{:ok, transport} ->
state = %{
options: options,
current_measurement: @hardcoded_value,
current_raw_measurement: @hardcoded_value,
transport: transport
}
# Request an initial sample and schedule the following ones.
send(self(), :do_sample)
interval = Keyword.get(init_arg, :measurement_interval, @default_interval)
{:ok, _tref} = :timer.send_interval(interval, :do_sample)
Logger.info("[SHT4X] Initialized | Options: #{inspect(state.options)}")
{:ok, state}
{:error, reason} ->
Logger.error("[SHT4X] Error connecting to sensor: #{inspect(reason)}")
{:stop, :normal}
end
end
@impl GenServer
def handle_info(:do_sample, state) do
compensation_callback = state.options[:compensation_callback]
case SHT4X.Comm.measure(state.transport, state.options) do
{:ok, data} ->
measurement_raw = SHT4X.Measurement.from_raw(data)
measurement_compensated = compensation_callback.(measurement_raw)
{:noreply,
%{
state
| current_measurement: measurement_compensated,
current_raw_measurement: measurement_raw
}}
_ ->
# Always call the compensation function on the last good raw reading we had, if there is one.
# Unless the quality of that sample is unusable.
if state.current_raw_measurement.quality == :unusable do
{:noreply, state}
else
measurement_compensated = compensation_callback.(state.current_raw_measurement)
check_staleness(state, measurement_compensated)
end
end
end
defp check_staleness(state, measurement_compensated) do
now = System.monotonic_time(:millisecond)
if now - measurement_compensated.timestamp_ms >= state.options[:stale_threshold] do
# Mark the current samples as stale
{:noreply,
%{
state
| current_measurement: %{measurement_compensated | quality: :stale},
current_raw_measurement: %{state.current_raw_measurement | quality: :stale}
}}
else
{:noreply, %{state | current_measurement: measurement_compensated}}
end
end
@impl GenServer
def handle_call(:get_sample, _from, state) do
{:reply, state.current_measurement, state}
end
def handle_call(:serial_number, _from, state) do
{:reply, SHT4X.Comm.serial_number(state.transport), state}
end
end