lib/sgp30.ex

defmodule SGP30 do
  @moduledoc File.read!("README.md")
             |> String.split("# Usage")
             |> Enum.fetch!(1)

  use GenServer

  require Logger

  alias Circuits.I2C
  alias SGP30.CRC

  @polling_interval_ms 900

  defstruct address: 0x58,
            serial: 0,
            tvoc_ppb: 0,
            co2_eq_ppm: 0,
            i2c: nil,
            h2_raw: 0,
            ethanol_raw: 0

  @type t() :: %__MODULE__{
          address: I2C.address(),
          serial: non_neg_integer(),
          tvoc_ppb: non_neg_integer(),
          co2_eq_ppm: non_neg_integer(),
          i2c: I2C.bus() | nil,
          h2_raw: non_neg_integer(),
          ethanol_raw: non_neg_integer()
        }

  @spec start_link(bus_name: String.t()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl GenServer
  def init(opts) do
    bus_name = opts[:bus_name] || "i2c-1"
    {:ok, i2c} = I2C.open(bus_name)
    {:ok, %__MODULE__{i2c: i2c}, {:continue, :serial}}
  end

  @spec state(GenServer.server()) :: t()
  def state(name \\ __MODULE__) do
    GenServer.call(name, :get_state)
  end

  @impl GenServer
  def handle_call(:get_state, _from, state), do: {:reply, state, state}

  @impl GenServer
  def handle_continue(:serial, %{address: address, i2c: i2c} = state) do
    state =
      with {:ok, <<word1::size(16), crc1, word2::size(16), crc2, word3::size(16), crc3>>} <-
             I2C.write_read(i2c, address, <<0x3682::size(16)>>, 9),
           true <- CRC.check([{word1, crc1}, {word2, crc2}, {word3, crc3}]) do
        %{state | serial: serial_as_integer(word1, word2, word3)}
      else
        false ->
          log_it("CRC check failed!", :error)
          state

        err ->
          log_it("serial read error: #{inspect(err)}", :error)
          state
      end

    _ = I2C.write(i2c, address, <<0x20, 0x03>>)
    Process.send_after(self(), :measure, @polling_interval_ms)

    {:noreply, state}
  end

  @impl GenServer
  def handle_info(:measure, state) do
    state =
      state
      |> track_event(:measure)
      |> track_event(:measure_raw)

    Process.send_after(self(), :measure, @polling_interval_ms)
    {:noreply, state}
  end

  defp execute_event(event, state) do
    case event do
      :measure -> measure(state)
      :measure_raw -> measure_raw(state)
    end
  end

  defp measure(state) do
    _ = I2C.write(state.i2c, state.address, <<0x20, 0x08>>)
    :timer.sleep(10)

    with {:ok, <<co2_eq_ppm::16, co2_crc::8, tvoc_ppb::16, tvoc_crc::8>>} <-
           I2C.read(state.i2c, state.address, 6),
         true <- CRC.check([{co2_eq_ppm, co2_crc}, {tvoc_ppb, tvoc_crc}]) do
      %{state | co2_eq_ppm: co2_eq_ppm, tvoc_ppb: tvoc_ppb}
    else
      {:error, err} ->
        log_it("measure error: #{inspect(err)}", :error)
        {:error, err, state}

      false ->
        log_it("CRC check failed", :error)
        {:error, "CRC check failed", :error}
    end
  end

  defp measure_raw(state) do
    _ = I2C.write(state.i2c, state.address, <<0x20, 0x50>>)
    :timer.sleep(20)

    with {:ok, <<h2_raw::16, h2_crc::8, ethanol_raw::16, ethanol_crc::8>>} <-
           I2C.read(state.i2c, state.address, 6),
         true <- CRC.check([{h2_raw, h2_crc}, {ethanol_raw, ethanol_crc}]) do
      %{state | h2_raw: h2_raw, ethanol_raw: ethanol_raw}
    else
      {:error, err} ->
        log_it("measure error: #{inspect(err)}", :error)
        {:error, err, state}

      false ->
        log_it("CRC check failed", :error)
        {:error, "CRC check failed", :error}
    end
  end

  defp log_it(str, level) do
    Logger.bare_log(level, ["[#{__MODULE__}] - ", str])
  end

  defp serial_as_integer(word1, word2, word3) do
    <<serial::48>> = <<word1::16, word2::16, word3::16>>
    serial
  end

  defp track_event(state, event) do
    name = [:sgp30, event]

    :telemetry.span(name, %{}, fn ->
      case execute_event(event, state) do
        {:error, err, state} ->
          {state, %{error: err}}

        updated ->
          :telemetry.execute(name, updated, %{system_time: System.monotonic_time()})
          {updated, %{}}
      end
    end)
  end
end