lib/jeff.ex

defmodule Jeff do
  @moduledoc """
  Control an Access Control Unit (ACU) and send commands to a Peripheral Device (PD)
  """

  alias Jeff.ACU
  alias Jeff.Command
  alias Jeff.Device
  alias Jeff.MFG.Encoder
  alias Jeff.Reply
  alias Jeff.Reply.ErrorCode
  alias Jeff.{ACU, Command, Command.FileTransfer, Device, Reply}

  @type acu() :: GenServer.server()
  @type device_opt() :: ACU.device_opt()
  @type osdp_address() :: 0x00..0x7F
  @type vendor_code() :: 0x000000..0xFFFFFF

  @type cmd_err :: {:error, :timeout | ErrorCode.t()}

  @osdp_max_packet_size 128
  @doc """
  Enable OSDP packet tracing

  Use log level `:debug`

  Available options:
    * `:ignore_polls` - OSDP polling has to occur at minimum every 200ms which
      can be noisy in the logs. When this is set, poll commands and ACK messages
      are ignored when tracing. Default `true`

    * `:ignore_partials` - Partial packets should be rare, but do occur when there
      is noise on the lines or during other communication synchronization problems.
      This ignores partial, incomplete packets when enabled. Default `false`

    * `:ignore_marks` - Before sending data, the the line is driven with a 0xFF
      byte to mark it for sending. This happens on ACU and PD. This is not always
      useful when tracing, so they can be ignored if all the partial bytes are
      0xFF. Default `true`

    * `:partials_limit` - Limit of partial packets to store in a buffer. This is
      most typically not used since partials get logged on next valid packet.
      However, it protects against cases where valid packets may not be happening
      and prevents the partial buffer from growing unbounded. Default `100`

  Tracing can also be configuraed in the application config with the same
  values using the `:tracer` key:

  ```
  config :jeff, :tracer,
    enabled: true,
    ignore_polls: true,
    ignore_partials: false,
    ignore_marks: true
  ```
  """
  @spec enable_trace(acu(), [Jeff.Tracer.option()]) :: :ok | {:error, File.posix()}
  def enable_trace(acu, opts \\ []) do
    GenServer.call(Jeff.ACU.state(acu).conn, {:set_trace, true, opts})
  end

  @doc """
  Disable OSDP packet tracing
  """
  @spec disable_trace(acu()) :: :ok | {:error, File.posix()}
  def disable_trace(acu) do
    GenServer.call(Jeff.ACU.state(acu).conn, {:set_trace, false, []})
  end

  @doc """
  Start an ACU process.
  """
  @spec start_acu([ACU.start_opt()]) :: GenServer.on_start()
  def start_acu(opts \\ []) do
    ACU.start_link(opts)
  end

  @doc """
  Register a peripheral device on the ACU communication bus.
  """
  @spec add_pd(acu(), osdp_address(), [device_opt()]) :: Device.t()
  def add_pd(acu, address, opts \\ []) do
    ACU.add_device(acu, address, opts)
  end

  @doc """
  Remove a peripheral device from the ACU communication bus.
  """
  @spec remove_pd(acu(), osdp_address()) :: Device.t()
  def remove_pd(acu, address) do
    ACU.remove_device(acu, address)
  end

  @doc """
  Requests the return of the PD ID Report.
  """
  @spec id_report(acu(), osdp_address()) :: Reply.IdReport.t() | cmd_err()
  def id_report(acu, address) do
    ACU.send_command(acu, address, ID) |> handle_reply()
  end

  @doc """
  Requests the PD to return a list of its functional capabilities, such as the
  type and number of input points, outputs points, reader ports, etc.
  """
  @spec capabilities(acu(), osdp_address()) :: Reply.Capabilities.t() | cmd_err()
  def capabilities(acu, address) do
    ACU.send_command(acu, address, CAP) |> handle_reply()
  end

  @doc """
  Instructs the PD to reply with a local status report.
  """
  @spec local_status(acu(), osdp_address()) :: Reply.LocalStatus.t() | cmd_err()
  def local_status(acu, address) do
    ACU.send_command(acu, address, LSTAT) |> handle_reply()
  end

  @doc """
  Controls the LEDs associated with one or more readers.
  """
  @spec set_led(acu(), osdp_address(), [Command.LedSettings.param()]) ::
          Reply.ACK | cmd_err()
  def set_led(acu, address, params) do
    ACU.send_command(acu, address, LED, params) |> handle_reply()
  end

  @doc """
  Defines commands to a single, monotone audible annunciator (beeper or buzzer)
  that may be associated with a reader.
  """
  @spec set_buzzer(acu(), osdp_address(), [Command.BuzzerSettings.param()]) ::
          Reply.ACK | cmd_err()
  def set_buzzer(acu, address, params) do
    ACU.send_command(acu, address, BUZ, params) |> handle_reply()
  end

  @doc """
  Sets the PD's communication parameters.
  """
  @spec set_com(acu(), osdp_address(), [Command.ComSettings.param()]) ::
          Reply.ComData.t() | cmd_err()
  def set_com(acu, address, params) do
    ACU.send_command(acu, address, COMSET, params) |> handle_reply()
  end

  @doc """
  Instructs the PD to reply with an input status report.
  """
  @spec input_status(acu(), osdp_address()) :: Reply.InputStatus.t() | cmd_err()
  def input_status(acu, address) do
    ACU.send_command(acu, address, ISTAT) |> handle_reply()
  end

  @doc """
  Instructs the PD to reply with an output status report.
  """
  @spec output_status(acu(), osdp_address()) :: Reply.OutputStatus.t() | cmd_err()
  def output_status(acu, address) do
    ACU.send_command(acu, address, OSTAT) |> handle_reply()
  end

  @doc """
  Sends a manufacturer-specific command to the PD.
  """
  @spec mfg(acu(), osdp_address(), Encoder.t() | [Command.Mfg.param()]) ::
          Reply.MfgReply.t() | cmd_err()
  def mfg(acu, address, mfg_command) when is_struct(mfg_command) do
    vendor_code = Encoder.vendor_code(mfg_command)
    data = Encoder.encode(mfg_command)

    mfg(acu, address, vendor_code: vendor_code, data: data)
  end

  def mfg(acu, address, params) when is_list(params) do
    ACU.send_command(acu, address, MFG, params) |> handle_reply()
  end

  @doc """
  Send file data to a PD
  """
  @spec file_transfer(acu(), osdp_address(), binary()) ::
          Reply.FileTransferStatus.t() | cmd_err()
  def file_transfer(acu, address, data) when is_binary(data) do
    print_progress_callback = fn num, total, percent ->
      {:ok, cols} = :io.columns()
      total_cols = round(cols * 0.7)
      progress_len = round(total_cols * (num / total))
      progress = :binary.copy("=", progress_len)
      trailing = :binary.copy(" ", total_cols - progress_len)
      result = "\r[#{progress}#{trailing}] #{num}/#{total} (%#{percent})"
      if num == total, do: IO.puts(result), else: IO.write(result)
    end

    file_transfer(acu, address, data, print_progress_callback)
  end

  @spec file_transfer(acu(), osdp_address(), binary(), function() | nil) ::
          Reply.FileTransferStatus.t() | cmd_err()
  def file_transfer(acu, address, data, progress_callback) when is_binary(data) do
    commands = FileTransfer.command_set(data, @osdp_max_packet_size)
    total_packets = length(commands)

    case commands do
      [] ->
        %Reply.FileTransferStatus{status: :malformed}

      _ ->
        call_progress_callback(progress_callback, 0, total_packets)
        run_file_transfer(commands, acu, address, progress_callback, total_packets)
    end
  end

  defp run_file_transfer([cmd | rem], acu, address, progress_callback, total_packets) do
    case ACU.send_command(acu, address, FILETRANSFER, Map.to_list(cmd)) do
      {:ok, reply} ->
        handle_file_transfer_reply(
          reply,
          rem,
          acu,
          address,
          progress_callback,
          total_packets
        )

      error ->
        error
    end
  end

  defp handle_file_transfer_reply(
         reply,
         rem,
         acu,
         address,
         progress_callback,
         total_packets
       ) do
    case FileTransfer.adjust_from_reply(reply, rem) do
      {:cont, next, delay} ->
        packet_num = total_packets - length(next)
        call_progress_callback(progress_callback, packet_num, total_packets)
        :timer.sleep(delay)
        run_file_transfer(next, acu, address, progress_callback, total_packets)

      {:halt, data} ->
        call_progress_callback(progress_callback, total_packets - length(rem), total_packets)
        data
    end
  end

  defp call_progress_callback(progress_callback, packet_num, total_packets)
       when is_function(progress_callback, 3) do
    progress_percentage = round(packet_num * 100 / total_packets)
    progress_callback.(packet_num, total_packets, progress_percentage)
  end

  defp call_progress_callback(_progress_callback, _packet_num, _total_packets), do: :ok

  defp handle_reply({:ok, %{data: %ErrorCode{code: code} = data}}) when code > 0,
    do: {:error, data}

  defp handle_reply({:ok, %{data: data}}), do: data
  defp handle_reply(err), do: err
end