lib/nerves_hub_link/connection.ex

defmodule NervesHubLink.Connection do
  @moduledoc """
  Agent used to keep the simple state of the devices connection
  to [nerves-hub.org](https://www.nerves-hub.org).

  The state is a tuple where the first element is an atom of `:connected` or
  `:disconnected` and the second element is the value of `System.monotonic_time/1`
  at the time of setting the new state.

  In practice, this state is set anytime the device connection to
  [nerves-hub.org](https://www.nerves-hub.org) channel changes.
  Likewise, it is set after a HTTP request fails or succeeds. This makes it
  useful when you want to consider the connection to
  [nerves-hub.org](https://www.nerves-hub.org) as part of the overall health
  of the device and perform explicit actions based on the result, such as using
  the Erlang [:heart](http://erlang.org/doc/man/heart.html) module to force a
  reboot if the callback check fails.

  ```
  # Set a callback for heart to check every 5 seconds. If the function returns anything other than
  # `:ok`, it will cause reboot.
  :heart.set_callback(NervesHubLink.Connection, :check!)
  ```

  Or, you can use the check as part of a separate function with other health checks as well
  ```
  defmodule MyApp.Checker do
    def health_check do
      with :ok <- NervesHubLink.Connection.check,
           :ok <- MyApp.another_check,
           :ok <- MyApp.yet_another_check,
      do
        :ok
      else
        err -> err
      end
    end
  end

  # Somewhere else in MyApp
  :heart.set_callback(MyApp.Checker, :health_check)
  ```
  """

  use Agent

  @spec start_link(any()) :: {:error, any()} | {:ok, pid()}
  def start_link(_) do
    # start in a disconnected state
    Agent.start_link(fn -> {:disconnected, current_time()} end, name: __MODULE__)
  end

  @doc """
  A simple check to see if the device is considered ok.

  This will still return `:ok` if the device is in a disconnected state,
  but within the `:connection_timeout` timeframe to allow for
  intermittent connection failures that are recoverable. Once the
  disconnection has exceeded the timeout, this check will be consided
  unhealthy.

  The default connection timeout is 15 minutes (900 seconds), but is configurable:
  ```
  # 60 second timeout
  config :nerves_hub, connection_timeout: 60
  ```
  """
  @spec check() :: :ok | {:error, {:disconnected_too_long, integer()}}
  def check() do
    timeout = Application.get_env(:nerves_hub, :connection_timeout, 900)
    now = current_time()

    Agent.get(__MODULE__, & &1)
    |> case do
      {:connected, _} -> :ok
      {:disconnected, time} when now - time <= timeout -> :ok
      {:disconnected, time} -> {:error, {:disconnected_too_long, time}}
    end
  end

  @doc """
  Same as `check/0`, but raises `RuntimeError` if the check fails
  """
  @spec check!() :: :ok
  def check!() do
    unless check() == :ok do
      raise "too much time has passed since a successful connection to NervesHubLink"
    end

    :ok
  end

  @doc """
  Sets the state to `{:connected, System.monotonic_time(:seconds)}`
  """
  @spec connected() :: :ok
  def connected() do
    Agent.update(__MODULE__, fn _ -> {:connected, current_time()} end)
  end

  @doc """
  Sets the state to `{:disconnected, System.monotonic_time(:seconds)}`
  """
  @spec disconnected() :: :ok
  def disconnected() do
    # If we are already in a disconnected state, then don't
    # overwrite the existing value so we can measure from
    # the first point of disconnect
    Agent.update(__MODULE__, fn state ->
      case state do
        {:disconnected, _time} = state -> state
        _ -> {:disconnected, current_time()}
      end
    end)
  end

  @doc """
  Reads the state directly without modification.
  """
  @spec read() :: {:connected, integer()} | {:disconnected, integer()}
  def read(), do: Agent.get(__MODULE__, & &1)

  defp current_time(), do: System.monotonic_time(:second)
end