lib/tcp/client.ex

defmodule Modbux.Tcp.Client do
  @moduledoc """
  API for Modbus TCP Client.
  """
  alias Modbux.Tcp.Client
  alias Modbux.Tcp
  use GenServer, restart: :permanent, shutdown: 500
  require Logger

  @timeout 2000
  @port 502
  @ip {0, 0, 0, 0}
  @active false
  @to 2000

  defstruct ip: nil,
            tcp_port: nil,
            socket: nil,
            timeout: @to,
            active: false,
            transid: 0,
            status: nil,
            d_pid: nil,
            msg_len: 0,
            pending_msg: %{},
            cmd: nil

  @type client_option ::
          {:ip, {byte(), byte(), byte(), byte()}}
          | {:active, boolean}
          | {:tcp_port, non_neg_integer}
          | {:timeout, non_neg_integer}

  @doc """
  Starts a Modbus TCP Client process.

  The following options are available:

    * `ip` - is the internet address of the desired Modbux TCP Server.
    * `tcp_port` - is the desired Modbux TCP Server tcp port number.
    * `timeout` - is the connection timeout.
    * `active` - (`true` or `false`) specifies whether data is received as
        messages (mailbox) or by calling `confirmation/1` each time `request/2` is called.

    The messages (when active mode is true) have the following form:

    `{:modbus_tcp, cmd, values}`

  ## Example

  ```elixir
  Modbux.Tcp.Client.start_link(ip: {10,77,0,2}, port: 502, timeout: 2000, active: true)
  ```
  """
  def start_link(params, opts \\ []) do
    GenServer.start_link(__MODULE__, params, opts)
  end

  @doc """
  Stops the Client.
  """
  def stop(pid) do
    GenServer.stop(pid)
  end

  @doc """
  Gets the state of the Client.
  """
  def state(pid) do
    GenServer.call(pid, :state)
  end

  @doc """
  Configure the Client (`status` must be `:closed`).

  The following options are available:

  * `ip` - is the internet address of the desired Modbux TCP Server.
  * `tcp_port` - is the Modbux TCP Server tcp port number .
  * `timeout` - is the connection timeout.
  * `active` - (`true` or `false`) specifies whether data is received as
       messages (mailbox) or by calling `confirmation/1` each time `request/2` is called.
  """
  def configure(pid, params) do
    GenServer.call(pid, {:configure, params})
  end

  @doc """
  Connect the Client to a Server.
  """
  def connect(pid) do
    GenServer.call(pid, :connect)
  end

  @doc """
  Close the tcp port of the Client.
  """
  def close(pid) do
    GenServer.call(pid, :close)
  end

  @doc """
  Send a request to Modbux TCP Server.

  `cmd` is a 4 elements tuple, as follows:
    - `{:rc, slave, address, count}` read `count` coils.
    - `{:ri, slave, address, count}` read `count` inputs.
    - `{:rhr, slave, address, count}` read `count` holding registers.
    - `{:rir, slave, address, count}` read `count` input registers.
    - `{:fc, slave, address, value}` force single coil.
    - `{:phr, slave, address, value}` preset single holding register.
    - `{:fc, slave, address, values}` force multiple coils.
    - `{:phr, slave, address, values}` preset multiple holding registers.
  """
  def request(pid, cmd) do
    GenServer.call(pid, {:request, cmd})
  end

  @doc """
  In passive mode (active: false), reads the confirmation of the connected Modbux Server.
  """
  def confirmation(pid) do
    GenServer.call(pid, :confirmation)
  end

  @doc """
  In passive mode (active: false), flushed the pending messages.
  """
  def flush(pid) do
    GenServer.call(pid, :flush)
  end

  # callbacks
  def init(args) do
    port = args[:tcp_port] || @port
    ip = args[:ip] || @ip
    timeout = args[:timeout] || @timeout
    status = :closed

    active =
      if args[:active] == nil do
        @active
      else
        args[:active]
      end

    state = %Client{ip: ip, tcp_port: port, timeout: timeout, status: status, active: active}
    {:ok, state}
  end

  def handle_call(:state, from, state) do
    Logger.debug("(#{__MODULE__}, :state) from: #{inspect(from)}")
    {:reply, state, state}
  end

  def handle_call({:configure, args}, _from, state) do
    case state.status do
      :closed ->
        port = args[:tcp_port] || state.tcp_port
        ip = args[:ip] || state.ip
        timeout = args[:timeout] || state.timeout
        d_pid = args[:d_pid] || state.d_pid

        active =
          if args[:active] == nil do
            state.active
          else
            args[:active]
          end

        new_state = %Client{state | ip: ip, tcp_port: port, timeout: timeout, active: active, d_pid: d_pid}
        {:reply, :ok, new_state}

      _ ->
        {:reply, :error, state}
    end
  end

  def handle_call(:connect, {from, _ref}, state) do
    Logger.debug("(#{__MODULE__}, :connect) state: #{inspect(state)}")
    Logger.debug("(#{__MODULE__}, :connect) from: #{inspect(from)}")

    case :gen_tcp.connect(
           state.ip,
           state.tcp_port,
           [:binary, packet: :raw, active: state.active],
           state.timeout
         ) do
      {:ok, socket} ->
        ctrl_pid =
          if state.d_pid == nil do
            from
          else
            state.d_pid
          end

        # state
        new_state = %Client{state | socket: socket, status: :connected, d_pid: ctrl_pid}
        {:reply, :ok, new_state}

      {:error, reason} ->
        Logger.error("(#{__MODULE__}, :connect) reason #{inspect(reason)}")
        # state
        {:reply, {:error, reason}, state}
    end
  end

  def handle_call(:close, _from, state) do
    Logger.debug("(#{__MODULE__}, :close) state: #{inspect(state)}")

    if state.socket != nil do
      new_state = close_socket(state)
      {:reply, :ok, new_state}
    else
      Logger.error("(#{__MODULE__}, :close) No port to close")
      # state
      {:reply, {:error, :closed}, state}
    end
  end

  def handle_call({:request, cmd}, _from, state) do
    Logger.debug("(#{__MODULE__}, :request) state: #{inspect(state)}")

    case state.status do
      :connected ->
        request = Tcp.pack_req(cmd, state.transid)
        length = Tcp.res_len(cmd)

        case :gen_tcp.send(state.socket, request) do
          :ok ->
            new_state =
              if state.active do
                new_msg = Map.put(state.pending_msg, state.transid, cmd)

                n_msg =
                  if state.transid + 1 > 0xFFFF do
                    0
                  else
                    state.transid + 1
                  end

                %Client{state | msg_len: length, cmd: cmd, pending_msg: new_msg, transid: n_msg}
              else
                %Client{state | msg_len: length, cmd: cmd}
              end

            {:reply, :ok, new_state}

          {:error, :closed} ->
            new_state = close_socket(state)
            {:reply, {:error, :closed}, new_state}

          {:error, reason} ->
            {:reply, {:error, reason}, state}
        end

      :closed ->
        {:reply, {:error, :closed}, state}
    end
  end

  # only in passive mode
  def handle_call(:confirmation, _from, state) do
    Logger.debug("(#{__MODULE__}, :confirmation) state: #{inspect(state)}")

    if state.active do
      {:reply, :error, state}
    else
      case state.status do
        :connected ->
          case :gen_tcp.recv(state.socket, state.msg_len, state.timeout) do
            {:ok, response} ->
              values = Tcp.parse_res(state.cmd, response, state.transid)
              Logger.debug("(#{__MODULE__}, :confirmation) response: #{inspect(response)}")

              n_msg =
                if state.transid + 1 > 0xFFFF do
                  0
                else
                  state.transid + 1
                end

              new_state = %Client{state | transid: n_msg, cmd: nil, msg_len: 0}

              case values do
                # escribió algo
                nil ->
                  {:reply, :ok, new_state}

                # leemos algo
                _ ->
                  {:reply, {:ok, values}, new_state}
              end

            {:error, reason} ->
              Logger.error("(#{__MODULE__}, :confirmation) reason: #{inspect(reason)}")
              # cerrar?
              new_state = close_socket(state)
              new_state = %Client{new_state | cmd: nil, msg_len: 0}
              {:reply, {:error, reason}, new_state}
          end

        :closed ->
          {:reply, {:error, :closed}, state}
      end
    end
  end

  def handle_call(:flush, _from, state) do
    new_state = %Client{state | pending_msg: %{}}
    {:reply, {:ok, state.pending_msg}, new_state}
  end

  # only for active mode (active: true)
  def handle_info({:tcp, _port, response}, state) do
    Logger.debug("(#{__MODULE__}, :message_active) response: #{inspect(response)}")
    Logger.debug("(#{__MODULE__}, :message_active) state: #{inspect(state)}")

    h = :binary.at(response, 0)
    l = :binary.at(response, 1)
    transid = h * 256 + l
    Logger.debug("(#{__MODULE__}, :message_active) transid: #{inspect(transid)}")

    case Map.fetch(state.pending_msg, transid) do
      :error ->
        Logger.error("(#{__MODULE__}, :message_active) unknown transaction id")
        {:noreply, state}

      {:ok, cmd} ->
        values = Tcp.parse_res(cmd, response, transid)
        msg = {:modbus_tcp, cmd, values}
        send(state.d_pid, msg)
        new_pending_msg = Map.delete(state.pending_msg, transid)
        new_state = %Client{state | cmd: nil, msg_len: 0, pending_msg: new_pending_msg}
        {:noreply, new_state}
    end
  end

  def handle_info({:tcp_closed, _port}, state) do
    Logger.info("(#{__MODULE__}, :tcp_close) Server close the port")
    new_state = close_socket(state)
    {:noreply, new_state}
  end

  def handle_info(msg, state) do
    Logger.error("(#{__MODULE__}, :random_msg) msg: #{inspect(msg)}")
    {:noreply, state}
  end

  defp close_socket(state) do
    :ok = :gen_tcp.close(state.socket)
    new_state = %Client{state | socket: nil, status: :closed}
    new_state
  end
end