lib/master.ex

defmodule Modbus.Tcp.Master do
  @moduledoc """
  TCP Master server.

  ## Example

  ```elixir
  # run with: mix opto22
  alias Modbus.Tcp.Master

  # opto22 learning center configured with script/opto22.otg
  # the otg is for an R2 but seems to work for R1, EB1, and EB2
  # digital points increment address by 4 per module and by 1 per point
  # analog points increment address by 8 per module and by 2 per point

  {:ok, pid} = Master.start_link(ip: {10, 77, 0, 10}, port: 502)

  # turn on 'alarm'
  :ok = Master.exec(pid, {:fc, 1, 4, 1})
  # turn on 'outside light'
  :ok = Master.exec(pid, {:fc, 1, 5, 1})
  # turn on 'inside light'
  :ok = Master.exec(pid, {:fc, 1, 6, 1})
  # turn on 'freezer door status'
  :ok = Master.exec(pid, {:fc, 1, 7, 1})

  :timer.sleep(400)

  # turn off all digital outputs
  :ok = Master.exec(pid, {:fc, 1, 4, [0, 0, 0, 0]})

  # read the 'emergency' switch
  {:ok, [0]} = Master.exec(pid, {:rc, 1, 8, 1})

  # read the 'fuel level' knob (0 to 10,000)
  {:ok, data} = Master.exec(pid, {:rir, 1, 32, 2})
  [_] = Modbus.IEEE754.from_2n_regs(data, :be)

  # write to the 'fuel display' (0 to 10,000)
  data = Modbus.IEEE754.to_2_regs(+5000.0, :be)
  :ok = Master.exec(pid, {:phr, 1, 16, data})
  ```
  """
  alias Modbus.Tcp
  @to 2000

  ##########################################
  # Public API
  ##########################################

  @doc """
  Opens the connection.

  `opts` is a keyword list where:
  - `ip` is the internet address to connect to.
  - `port` is the tcp port number to connect to.
  - `timeout` is the optional connection timeout.

  Returns `{:ok, pid}` | `{:error, reason}`.

  ## Example

  ```elixir
  Modbus.Tcp.Master.start_link(ip: {10,77,0,10}, port: 502, timeout: 2000)
  ```
  """

  def start_link(opts) do
    ip = Keyword.fetch!(opts, :ip)
    port = Keyword.fetch!(opts, :port)
    timeout = Keyword.get(opts, :timeout, @to)
    init = %{ip: ip, port: port, timeout: timeout}
    GenServer.start_link(__MODULE__.Server, init)
  end

  @doc """
  Closes the connection.
  """
  def stop(pid) do
    GenServer.stop(pid)
  end

  @doc """
  Executes a Modbus TCP command.

  `cmd` is one of:

  - `{: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.

  Returns `:ok` | `{:ok, [values]}` | `{:error, reason}`.
  """
  def exec(pid, cmd, timeout \\ @to) when is_tuple(cmd) and is_integer(timeout) do
    GenServer.call(pid, {:exec, cmd, timeout})
  end

  defmodule Server do
    @moduledoc false
    use GenServer

    def init(init) do
      %{ip: ip, port: port, timeout: timeout} = init
      opts = [:binary, packet: :raw, active: false]

      case :gen_tcp.connect(ip, port, opts, timeout) do
        {:ok, sid} ->
          state = %{socket: sid, transid: 0}
          {:ok, state}

        {:error, reason} ->
          {:stop, reason}
      end
    end

    def handle_call(:socket, _from, state) do
      {:reply, state.socket, state}
    end

    def handle_call({:exec, cmd, timeout}, _from, state) do
      %{socket: socket, transid: transid} = state
      resp = exec(socket, cmd, transid, timeout)
      {:reply, resp, Map.put(state, :transid, transid + 1)}
    end

    defp exec(socket, cmd, transid, timeout) do
      case request(cmd, transid) do
        {:ok, request, length} ->
          # clear input buffer
          :gen_tcp.recv(socket, 0, 0)

          case :gen_tcp.send(socket, request) do
            :ok ->
              case :gen_tcp.recv(socket, length, timeout) do
                {:ok, response} ->
                  values = Tcp.parse_res(cmd, response, transid)

                  case values do
                    nil -> :ok
                    _ -> {:ok, values}
                  end

                error ->
                  error
              end

            error ->
              error
          end

        error ->
          error
      end
    end

    defp request(cmd, transid) do
      try do
        request = Tcp.pack_req(cmd, transid)
        length = Tcp.res_len(cmd)
        {:ok, request, length}
      rescue
        _ ->
          {:error, {:invalid, cmd}}
      end
    end
  end
end