lib/rtu/master.ex

defmodule Modbux.Rtu.Master do
  @moduledoc """
  API for a Modbus RTU Master device.
  """
  use GenServer, restart: :transient

  alias Modbux.Rtu.{Master, Framer}
  alias Modbux.Rtu
  alias Circuits.UART
  require Logger

  @timeout 1000
  @speed 115_200

  defstruct tty: nil,
            timeout: nil,
            cmd: nil,
            active: false,
            uart_opts: nil,
            uart_pid: nil,
            parent_pid: nil

  @doc """
  Starts a Modbus RTU Master process.

  The following options are available:

    * `tty` - defines the serial port to spawn the Master.
    * `timeout` - defines slave timeout.
    * `active` - (`true` or `false`) specifies whether data is received as
        messages (mailbox) or by calling `request/2`.
    * `gen_opts` - defines extra options for the Genserver OTP configuration.
    * `uart_opts` - defines extra options for the UART configuration (defaults:
          [speed: 115200, rx_framing_timeout: 1000]).

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

    `{:modbus_rtu, {:slave_response, cmd, values}}`
  or

    `{:modbus_rtu, {:slave_error, payload, reason}}`

  The following are some reasons:

    * `:ecrc`  - corrupted message (invalid crc).
    * `:einval`  - invalid function.
    * `:eaddr`  - invalid memory address requested.

  ## Example

  ```elixir
  Modbux.Rtu.Master.start_link(tty: "tnt0", active: true, uart_opts: [speed: 9600])
  ```
  """
  @spec start_link(keyword) :: :ignore | {:error, any} | {:ok, pid}
  def start_link(params) do
    gen_opts = Keyword.get(params, :gen_opts, [])
    GenServer.start_link(__MODULE__, {params, self()}, gen_opts)
  end

  @spec stop(atom | pid | {atom, any} | {:via, atom, any}) :: :ok
  def stop(pid) do
    GenServer.stop(pid)
  end

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

  @doc """
  Configure the Master serial port.

  The following options are available:

    * `tty` - defines the serial port to spawn the Master.
    * `timeout` - defines slave timeout.
    * `active` - (`true` or `false`) specifies whether data is received as
        messages (mailbox) or by calling `request/2`.
    * `gen_opts` - defines extra options for the Genserver OTP configuration.
    * `uart_opts` - defines extra options for the UART configuration.

  """
  def configure(pid, params) do
    GenServer.call(pid, {:configure, {params, self()}})
  end

  @doc """
  Open the Master serial port.
  """
  def open(pid) do
    GenServer.call(pid, :open)
  end

  @doc """
  Close the Master serial port.
  """
  def close(pid) do
    GenServer.call(pid, :close)
  end

  @doc """
  Send a request to Modbus RTU Slave.

  `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.
  """
  @spec request(atom | pid | {atom, any} | {:via, atom, any}, tuple()) ::
          :ok | {:ok, list()} | {:error, String.t()}
  def request(pid, cmd) do
    GenServer.call(pid, {:request, cmd})
  end

  @doc """
  Read and parse the last request (if the last request timeouts).
  """
  @spec read(atom | pid | {atom, any} | {:via, atom, any}) :: any
  def read(pid) do
    GenServer.call(pid, :read)
  end

  def terminate(:normal, _state), do: nil

  def terminate(reason, state) do
    Logger.error("(#{__MODULE__}) Error: #{inspect(reason)}, state: #{inspect(state)}")
  end

  # Callbacks
  def init({params, parent_pid}) do
    active = Keyword.get(params, :active, false)
    parent_pid = if active, do: parent_pid
    timeout = Keyword.get(params, :timeout, @timeout)
    tty = Keyword.fetch!(params, :tty)
    Logger.debug("(#{__MODULE__}) Starting Modbux Master at \"#{tty}\"")
    uart_opts = Keyword.get(params, :uart_opts, speed: @speed, rx_framing_timeout: @timeout)
    {:ok, u_pid} = UART.start_link()
    UART.open(u_pid, tty, [framing: {Framer, behavior: :master}, active: false] ++ uart_opts)
    Logger.debug("(#{__MODULE__}) Reported UART configuration: \"#{inspect(UART.configuration(u_pid))}\"")

    state = %Master{
      parent_pid: parent_pid,
      tty: tty,
      active: active,
      uart_pid: u_pid,
      timeout: timeout,
      uart_opts: uart_opts
    }

    {:ok, state}
  end

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

  def handle_call(:read, _from, state) do
    res = unless is_nil(state.cmd), do: uart_read(state, state.cmd)
    {:reply, res, state}
  end

  def handle_call(:open, _from, %{uart_pid: u_pid, tty: tty, uart_opts: uart_opts} = state) do
    UART.open(u_pid, tty, [framing: {Framer, behavior: :master}, active: false] ++ uart_opts)
    {:reply, :ok, state}
  end

  def handle_call(:close, _from, state) do
    UART.close(state.uart_pid)
    {:reply, :ok, state}
  end

  def handle_call({:request, cmd}, _from, state) do
    uart_frame = Rtu.pack_req(cmd)
    Logger.debug("(#{__MODULE__}) Frame: #{inspect(uart_frame, base: :hex)}")
    UART.flush(state.uart_pid)
    UART.write(state.uart_pid, uart_frame)

    res =
      if state.active do
        Task.start_link(__MODULE__, :async_uart_read, [state, cmd])
        :ok
      else
        uart_read(state, cmd)
      end

    {:reply, res, %{state | cmd: cmd}}
  end

  def handle_call({:configure, {params, parent_pid}}, _from, state) do
    active = Keyword.get(params, :active, false)
    parent_pid = if active, do: parent_pid
    timeout = Keyword.get(params, :timeout, state.timeout)
    tty = Keyword.get(params, :tty, state.tty)
    uart_opts = Keyword.get(params, :uart_opts, state.uart_opts)
    Logger.debug("(#{__MODULE__}) Starting Modbux Master at \"#{tty}\"")

    UART.close(state.uart_pid)
    UART.stop(state.uart_pid)

    {:ok, u_pid} = UART.start_link()
    UART.open(u_pid, tty, [framing: {Framer, behavior: :master}, active: false] ++ uart_opts)

    new_state = %Master{
      parent_pid: parent_pid,
      tty: tty,
      active: active,
      uart_pid: u_pid,
      timeout: timeout,
      uart_opts: uart_opts
    }

    {:reply, :ok, new_state}
  end

  # Catch all clause
  def handle_info(msg, state) do
    Logger.warning("(#{__MODULE__}) Unknown msg: #{inspect(msg)}")
    {:noreply, state}
  end

  def async_uart_read(state, cmd) do
    uart_read(state, cmd) |> notify(state, cmd)
  end

  defp uart_read(state, cmd) do
    case UART.read(state.uart_pid, state.timeout) do
      {:ok, ""} ->
        Logger.warning("(#{__MODULE__}) Timeout")
        {:error, :timeout}

      {:ok, {:error, reason, msg}} ->
        Logger.warning("(#{__MODULE__}) Error in frame: #{inspect(msg)}, reason: #{inspect(reason)}")
        {:error, reason}

      {:ok, slave_response} ->
        Rtu.parse_res(cmd, slave_response) |> pack_res()

      {:error, reason} ->
        Logger.warning("(#{__MODULE__}) Error: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp notify({:error, reason}, state, cmd),
    do: send(state.parent_pid, {:modbus_rtu, {:slave_error, cmd, reason}})

  defp notify({:ok, slave_response}, state, cmd),
    do: send(state.parent_pid, {:modbus_rtu, {:slave_response, cmd, slave_response}})

  defp notify(:ok, state, cmd), do: send(state.parent_pid, {:modbus_rtu, {:slave_response, cmd, :ok}})

  defp pack_res(nil), do: :ok
  defp pack_res(value) when is_tuple(value), do: value
  defp pack_res(value), do: {:ok, value}
end