lib/master.ex

defmodule Modbus.Rtu.Master do
  @moduledoc """
  RTU Master server.

  ```elixir
  # run with: mix slave
  alias Modbus.Rtu.Slave
  alias Modbus.Rtu.Master

  tty0 =
    case :os.type() do
      {:unix, :darwin} -> "/dev/tty.usbserial-FTYHQD9MA"
      {:unix, :linux} -> "/dev/ttyUSB0"
      {:win32, :nt} -> "COM5"
    end

  tty1 =
    case :os.type() do
      {:unix, :darwin} -> "/dev/tty.usbserial-FTYHQD9MB"
      {:unix, :linux} -> "/dev/ttyUSB1"
      {:win32, :nt} -> "COM6"
    end

  # start your slave with a shared model
  model = %{
    0x50 => %{
      {:c, 0x5152} => 0,
      {:i, 0x5354} => 0,
      {:i, 0x5355} => 1,
      {:hr, 0x5657} => 0x6162,
      {:ir, 0x5859} => 0x6364,
      {:ir, 0x585A} => 0x6566
    }
  }

  {:ok, _} = Slave.start_link(model: model, device: tty0)
  {:ok, mpid} = Master.start_link(device: tty1)

  # read input
  {:ok, [0, 1]} = Master.exec(mpid, {:ri, 0x50, 0x5354, 2})
  # read input registers
  {:ok, [0x6364, 0x6566]} = Master.exec(mpid, {:rir, 0x50, 0x5859, 2})

  # toggle coil and read it back
  :ok = Master.exec(mpid, {:fc, 0x50, 0x5152, 0})
  {:ok, [0]} = Master.exec(mpid, {:rc, 0x50, 0x5152, 1})
  :ok = Master.exec(mpid, {:fc, 0x50, 0x5152, 1})
  {:ok, [1]} = Master.exec(mpid, {:rc, 0x50, 0x5152, 1})

  # increment holding register and read it back
  {:ok, [0x6162]} = Master.exec(mpid, {:rhr, 0x50, 0x5657, 1})
  :ok = Master.exec(mpid, {:phr, 0x50, 0x5657, 0x6163})
  {:ok, [0x6163]} = Master.exec(mpid, {:rhr, 0x50, 0x5657, 1})
  ```

  Uses:

  - https://github.com/samuelventura/sniff
  - https://github.com/samuelventura/modbus
  """
  alias Modbus.Rtu
  @sleep 10
  @to 400

  @doc """
  Opens the connection.

  `params` *must* contain a keyword list to be merged with the following defaults:
  ```elixir
  [
    device: nil,        #serial port name: "COM1", "/dev/ttyUSB0", "/dev/tty.usbserial-FTYHQD9MA"
    speed: 9600,        #either 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200
                        #win32 adds 14400, 128000, 256000
    config: "8N1",      #either "8N1", "7E1", "7O1"
  ]
  ```

  Returns `{:ok, pid}` | `{:error, reason}`.
  ## Example
    ```
    Modbus.Rtu.Master.start_link(device: "/dev/ttyUSB0")
    ```
  """
  def start_link(opts) do
    device = Keyword.fetch!(opts, :device)
    speed = Keyword.get(opts, :speed, 9600)
    config = Keyword.get(opts, :config, "8N1")
    sleep = Keyword.get(opts, :sleep, @sleep)
    init = [device: device, speed: speed, config: config, sleep: sleep]
    GenServer.start_link(__MODULE__.Server, init)
  end

  @doc """
    Closes the connection.

    Returns `:ok`.
  """
  def stop(pid) do
    GenServer.stop(pid)
  end

  @doc """
  Executes a Modbus 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
      case Baud.start_link(init) do
        {:ok, bid} ->
          {:ok, bid}

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

    def handle_call({:exec, cmd, timeout}, _from, baud) do
      resp = exec(baud, cmd, timeout)
      {:reply, resp, baud}
    end

    defp exec(baud, cmd, timeout) do
      case request(cmd) do
        {:ok, request, length} ->
          # clear input buffer
          Baud.readall(baud)

          case Baud.write(baud, request) do
            :ok ->
              case Baud.readn(baud, length, timeout) do
                {:ok, response} ->
                  values = Rtu.parse_res(cmd, response)

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

                {:to, partial} ->
                  {:error, {:to, partial}}

                error ->
                  error
              end

            error ->
              error
          end

        error ->
          error
      end
    end

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