lib/libnfc/mock.ex

# SPDX-License-Identifier: Apache-2.0

defmodule LibNFC.Mock do
  @moduledoc """
  NFC device emulation for testing purposes without physical interaction with a NFC device.

  This module allows to emulate the presence of NFC targets in range of a "mock" device.
  `LibNFC.Mock` is a GenServer (to be spawned within a supervision tree) that holds the status
  of NFC targets in range. Functions on `LibNFC` accepting a `:mock` parameter instead of the
  NFC device reference will fetch data from this server instead of actually talking to libnfc.
  """

  @moduledoc since: "0.1.0"

  use GenServer

  @nif_delay_ms 400
  @nif_delay_jitter 0.5

  @type modulation :: atom
  @type target_info :: %{required(String.t()) => binary}

  @type target :: %{
          required(:modulation) => modulation,
          required(:target_info) => target_info
        }

  @typedoc """
  Server options

  * `nif_delay` emulates delay of NIF calls (default: #{@nif_delay_ms})
  * `nif_delay_jitter` jitter factor for delay (default: #{@nif_delay_jitter})
  """
  @type server_options :: [
          {:nif_delay, non_neg_integer | false}
          | {:nif_delay_jitter, non_neg_integer}
        ]

  @doc false
  @spec child_spec(server_options) :: Supervisor.child_spec()
  def child_spec(opts) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [opts]}
    }
  end

  @doc """
  Starts the mock process.
  """
  @doc since: "0.1.0"
  @spec start_link() :: GenServer.on_start()
  @spec start_link(server_options) :: GenServer.on_start()
  def start_link(opts \\ []) when is_list(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Puts a NFC target in range.
  """
  @doc since: "0.1.0"
  @spec put_target() :: :ok
  @spec put_target(target_info) :: :ok
  @spec put_target(target_info, modulation) :: :ok
  def put_target(target_info \\ %{"uid" => :rand.bytes(4)}, modulation \\ :iso14443a)

  def put_target(target_info, modulation) when is_binary(target_info) do
    put_target(%{"uid" => target_info}, modulation)
  end

  def put_target(target_info, modulation) when is_map(target_info) do
    GenServer.call(__MODULE__, {:put_target, {target_info, modulation}})
  end

  @doc """
  Removes the NFC target.
  """
  @doc since: "0.1.0"
  @spec clear_target() :: :ok
  def clear_target do
    GenServer.call(__MODULE__, :clear_target)
  end

  @doc false
  @spec initiator_select_passive_target(modulation) :: {:ok, target_info} | nil
  def initiator_select_passive_target(modulation) do
    GenServer.call(__MODULE__, {:select_target, modulation})
  end

  @doc false
  @spec initiator_target_is_present() :: integer()
  def initiator_target_is_present do
    GenServer.call(__MODULE__, :target_is_present)
  end

  @impl GenServer
  def init(opts) do
    {:ok, %{target: nil, selected: false, opts: opts}}
  end

  @impl GenServer
  def handle_call(msg, _from, %{opts: opts} = state) do
    {emulated_nif_call?, reply, new_state} =
      case {msg, state} do
        {{:put_target, {target_info, modulation}}, state} ->
          {false, :ok, %{state | target: %{target_info: target_info, modulation: modulation}}}

        {:clear_target, state} ->
          {false, :ok, %{state | target: nil}}

        {{:select_target, nm}, %{target: %{modulation: nm, target_info: tai}}} ->
          {true, tai, %{state | selected: true}}

        {{:select_target, _nm}, _nil_or_different_nm} ->
          {true, nil, state}

        {:target_is_present, %{target: target, selected: true}} when not is_nil(target) ->
          {true, 0, state}

        {:target_is_present, %{target: nil, selected: true}} ->
          {true, -2, %{state | selected: false}}

        {:target_is_present, _not_selected} ->
          {true, -10, state}
      end

    if emulated_nif_call? do
      emulate_nif_delay(opts)
    end

    {:reply, reply, new_state}
  end

  defp emulate_nif_delay(opts) do
    if nif_delay_ms = Keyword.get(opts, :nif_delay, @nif_delay_ms) do
      nif_delay_jitter = Keyword.get(opts, :nif_delay_jitter, @nif_delay_jitter)
      nif_delay_jitter = trunc(nif_delay_ms * nif_delay_jitter)

      Process.sleep(nif_delay_ms + :rand.uniform(nif_delay_jitter) - div(nif_delay_jitter, 2))
    end
  end
end