# 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