lib/rtu/slave.ex

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

  alias Modbux.Model.Shared
  alias Modbux.Rtu.{Slave, Framer}
  alias Modbux.Rtu
  alias Circuits.UART
  require Logger

  @timeout 1000
  @speed 115_200

  defstruct model_pid: nil,
            uart_pid: nil,
            tty: nil,
            uart_opts: nil,
            parent_pid: nil

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

  The following options are available:

    * `tty` - defines the serial port to spawn the Slave.
    * `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]).
    * `model` - defines the DB initial state.
    * `active` - (`true` or `false`) enable/disable DB updates notifications (mailbox).

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

    `{:modbus_rtu, {:slave_request, payload}}`

  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.

  ## Model (DB)

  The model or data base (DB) defines the slave memory map, the DB is defined by the following syntax:
  ```elixir
  %{slave_id => %{{memory_type, address_number} => value}}
  ```
  where:
    * `slave_id` - specifies a unique unit address from 1 to 247.
    * `memory_type` - specifies the memory between:
         * `:c` - Discrete Output Coils.
         * `:i` - Discrete Input Contacts.
         * `:ir` - Analog Input Registers.
         * `:hr` - Analog Output Registers.
    * `address_number` - specifies the memory address.
    * `value` - the current value from that memory.

  ## Example

  ```elixir
  model = %{80 => %{{:c, 20818} => 0, {:hr, 20818} => 0}}
  Modbux.Tcp.Server.start_link(model: model, port: 2000)
  ```
  """
  @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 Slave state.
  """
  @spec state(atom | pid | {atom, any} | {:via, atom, any}) :: any
  def state(pid) do
    GenServer.call(pid, :state)
  end

  @doc """
  Updates the state of the Slave DB.

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

  @doc """
  Gets the current state of the Slave DB.
  """
  @spec get_db(atom | pid | {atom, any} | {:via, atom, any}) :: any
  def get_db(pid) do
    GenServer.call(pid, :get_db)
  end

  @doc """
  Send a raw frame through the serial port.
  """
  @spec raw_write(atom | pid | {atom, any} | {:via, atom, any}, any) :: any
  def raw_write(pid, data) do
    GenServer.call(pid, {:raw_write, data})
  end

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

    state = %Slave{
      model_pid: model_pid,
      parent_pid: parent_pid,
      tty: tty,
      uart_pid: u_pid,
      uart_opts: uart_opts
    }

    {:ok, state}
  end

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

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

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

  def handle_call({:request, cmd}, _from, state) do
    res =
      case Shared.apply(state.model_pid, cmd) do
        {:ok, values} ->
          Logger.debug("(#{__MODULE__}) DB request: #{inspect(cmd)}, #{inspect(values)}")
          values

        nil ->
          Logger.debug("(#{__MODULE__}) DB update: #{inspect(cmd)}")

        error ->
          Logger.debug("(#{__MODULE__}) An error has occur #{inspect(error)}")
          error
      end

    {:reply, res, state}
  end

  def handle_call(:get_db, _from, state) do
    {:reply, Shared.state(state.model_pid), state}
  end

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

  def handle_info({:circuits_uart, device, {:error, reason, bad_frame}}, state) do
    Logger.warning("(#{__MODULE__}) Error with \"#{device}\" received: #{inspect(bad_frame, base: :hex)}, reason: #{reason}")

    case reason do
      :einval ->
        if valid_slave_id?(state, bad_frame) do
          response = Rtu.pack_res(bad_frame, :einval)
          Logger.debug("(#{__MODULE__}) Sending error code: #{inspect(response)}, reason: #{reason}")
          UART.write(state.uart_pid, response)
        end

      _ ->
        nil
    end

    if !is_nil(state.parent_pid), do: notify(state.parent_pid, reason, bad_frame)
    {:noreply, state}
  end

  def handle_info({:circuits_uart, _device, {:partial, data}}, state) do
    Logger.warning("(#{__MODULE__})  Timeout: #{inspect(data)}")
    {:noreply, state}
  end

  def handle_info({:circuits_uart, device, modbus_frame}, state) do
    Logger.debug("(#{__MODULE__}) Recieved from UART (#{device}): #{inspect(modbus_frame)}")
    cmd = Rtu.parse_req(modbus_frame)
    Logger.debug("(#{__MODULE__}) Received Modbux request: #{inspect(cmd)}")

    case Shared.apply(state.model_pid, cmd) do
      {:ok, values} ->
        response = Rtu.pack_res(cmd, values)
        if !is_nil(state.parent_pid), do: notify(state.parent_pid, nil, cmd)
        UART.write(state.uart_pid, response)

      {:error, reason} ->
        response = Rtu.pack_res(modbus_frame, reason)
        if !is_nil(state.parent_pid), do: notify(state.parent_pid, reason, cmd)
        UART.write(state.uart_pid, response)

        Logger.debug(
          "(#{__MODULE__}) An error has occur for cmd: #{inspect(cmd)}, response #{inspect(response)}"
        )

      nil ->
        nil
    end

    {:noreply, state}
  end

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

  defp valid_slave_id?(state, <<slave_id, _b_tail::binary>>) do
    state.model_pid
    |> Shared.state()
    |> Map.has_key?(slave_id)
  end

  defp notify(pid, nil, cmd) do
    send(pid, {:modbus_rtu, {:slave_request, cmd}})
  end

  defp notify(pid, reason, cmd) do
    send(pid, {:modbus_rtu, {:slave_error, cmd, reason}})
  end
end