# SPDX-License-Identifier: Apache-2.0 defmodule LibNFC.Presence do @moduledoc """ Passive tag monitor process. - Repeatedly polls for passive targets in range. - Emits an event when target enters. - Emits another event when target leaves. Target presence is tracked by polling `nfc_initiator_target_is_present` with a short debounce. ## Usage defmodule MyApp.NFC do use LibNFC.Presence @impl true def handle_target_in(target, state) do # ... {:ok, state} end @impl true def handle_target_out(state) do # ... {:ok, state} end end defmodule MyApp.Application do def start(_, _) do children = [ ... {MyApp.NFC, client_state: :foo} ] Supervisor.start_link(...) end end """ @moduledoc since: "0.1.0" use GenServer @schedule_delay_ms 300 @alive_after_last_seen_ms 500 @typedoc """ Arbitrary client state held within the presence monitor process. """ @type client_state :: any @typedoc """ Server Options * `client_state` initial client state (default: nil) * `mock` when set to true, default device will be the mock (default: false) * `schedule_delay` poll interval in ms (default: #{@schedule_delay_ms}) * `alive_after_last_seen` debounce leave event for a short period as some calls to `nfc_initiator_target_is_present` come back as false negatives (default: #{@alive_after_last_seen_ms}) """ @type server_options :: [ {:client_state, client_state} | {:mock, boolean} | {:schedule_delay, non_neg_integer} | {:alive_after_last_seen, non_neg_integer} ] @doc """ Called on process initialization to open the NFC reader device. Optional callback. Defaults to opening the "first" device found by calling `nfc_open` without a connstring. """ @doc since: "0.1.0" @callback open_device(client_state) :: {:ok, LibNFC.device()} @doc """ Emitted when a passive target is detected. """ @doc since: "0.1.0" @callback handle_target_in(LibNFC.target_info(), client_state) :: {:ok, client_state} @doc """ Emitted when selected target left. """ @doc since: "0.1.0" @callback handle_target_out(client_state) :: {:ok, client_state} @optional_callbacks open_device: 1 defmacro __using__(_) do quote do import LibNFC.Utils @behaviour LibNFC.Presence @spec child_spec(LibNFC.Presence.server_options()) :: Supervisor.child_spec() def child_spec(opts) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [opts]} } end @spec start_link(LibNFC.Presence.server_options()) :: GenServer.on_start() def start_link(opts \\ []) do LibNFC.Presence.start_link(__MODULE__, opts) end end end @doc """ Starts the passive tag monitor process. """ @doc since: "0.1.0" @spec start_link(module, server_options) :: GenServer.on_start() def start_link(client, opts) when is_atom(client) and is_list(opts) do GenServer.start_link(__MODULE__, {client, opts}) end @impl true def init({client, opts}) when is_atom(client) do {client_state, opts} = Keyword.pop(opts, :client_state) {:ok, ref} = cond do function_exported?(client, :open_device, 1) -> client.open_device(client_state) Keyword.get(opts, :mock) -> {:ok, :mock} true -> LibNFC.open() end schedule(0, opts) {:ok, %{client: client, client_state: client_state, ref: ref, opts: opts, scan: :idle}} end @impl true def handle_info({:run, n}, %{opts: opts, scan: :idle} = state) do state = case LibNFC.initiator_select_passive_target(state.ref) do nil -> state target -> :handle_target_in |> callback([target], state) |> select(target) end schedule(n, opts) {:noreply, state} end def handle_info({:run, n}, %{opts: opts, scan: {:selected, target, last_seen}} = state) do state = cond do LibNFC.initiator_target_is_present?(state.ref) -> select(state, target) consider_alive?(last_seen, opts) -> state true -> :handle_target_out |> callback([], state) |> Map.put(:scan, :idle) end schedule(n, opts) {:noreply, state} end defp schedule(prev, opts) do schedule_delay_ms = Keyword.get(opts, :schedule_delay, @schedule_delay_ms) Process.send_after(self(), {:run, prev + 1}, schedule_delay_ms) end defp callback(which, args, state) do {:ok, new_client_state} = apply(state.client, which, args ++ [state.client_state]) %{state | client_state: new_client_state} end defp select(state, target) do %{state | scan: {:selected, target, now()}} end defp now, do: DateTime.utc_now() defp consider_alive?(last_seen, opts) do alive_after_last_seen_ms = Keyword.get(opts, :alive_after_last_seen, @alive_after_last_seen_ms) DateTime.diff(now(), last_seen, :microsecond) < alive_after_last_seen_ms * 1000 end end